diff --git a/Makefile b/Makefile index 22e01d5..644b409 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BIN ?= strfry -APPS ?= dbutils relay mesh +APPS ?= dbutils relay mesh web OPT ?= -O3 -g include golpe/rules.mk diff --git a/src/apps/web/AlgoParser.h b/src/apps/web/AlgoParser.h new file mode 100644 index 0000000..9ccd20e --- /dev/null +++ b/src/apps/web/AlgoParser.h @@ -0,0 +1,435 @@ +#include +#include + +#include +#include + +#include "events.h" +#include "Bech32Utils.h" + + + + +struct AlgoCompiled { + double threshold = 20; + using PubkeySet = flat_hash_set; + std::vector pubkeySets; + flat_hash_map variableIndexLookup; // variableName -> index into pubkeySets + + PubkeySet *mods = nullptr; + PubkeySet *voters = nullptr; + + struct Filter { + std::unique_ptr re; + char op; + double arg; + }; + + std::vector filters; + + void updateScore(lmdb::txn &txn, Decompressor &decomp, const defaultDb::environment::View_Event &e, double &score) { + auto rawJson = getEventJson(txn, decomp, e.primaryKeyId); + re2::StringPiece rawJsonSP(rawJson); + + for (const auto &f : filters) { + if (!RE2::PartialMatch(rawJsonSP, *f.re)) continue; + + if (f.op == '+') score += f.arg; + else if (f.op == '-') score -= f.arg; + else if (f.op == '*') score *= f.arg; + else if (f.op == '/') score /= f.arg; + } + } +}; + + +struct AlgoParseState { + lmdb::txn &txn; + + AlgoCompiled a; + + struct ExpressionState { + std::string currInfixOp; + AlgoCompiled::PubkeySet set; + }; + + std::vector expressionStateStack; + std::string currPubkeyDesc; + std::vector currModifiers; + + std::string currSetterVar; + char currFilterOp; + double currFilterArg; + + AlgoParseState(lmdb::txn &txn) : txn(txn) {} + + void letStart(std::string_view name) { + if (a.variableIndexLookup.contains(name)) throw herr("overwriting variable: ", name); + a.variableIndexLookup[name] = a.pubkeySets.size(); + expressionStateStack.push_back({ "+" }); + } + + void letEnd() { + a.pubkeySets.emplace_back(std::move(expressionStateStack.back().set)); + expressionStateStack.clear(); + } + + void letAddExpression() { + const auto &id = currPubkeyDesc; + AlgoCompiled::PubkeySet set; + + if (id.starts_with("npub1")) { + set.insert(decodeBech32Simple(id)); + } else { + if (!a.variableIndexLookup.contains(id)) throw herr("variable not found: ", id); + auto n = a.variableIndexLookup[id]; + if (n >= a.pubkeySets.size()) throw herr("self referential variable: ", id); + set = a.pubkeySets[n]; + } + + for (const auto &m : currModifiers) { + if (m == "following") { + AlgoCompiled::PubkeySet newSet = set; + for (const auto &p : set) loadFollowing(p, newSet); + set = newSet; + } else { + throw herr("unrecognised modifier: ", m); + } + } + + currPubkeyDesc = ""; + currModifiers.clear(); + + mergeInfix(set); + } + + void mergeInfix(AlgoCompiled::PubkeySet &set) { + auto &currInfixOp = expressionStateStack.back().currInfixOp; + + if (currInfixOp == "+") { + for (const auto &e : set) { + expressionStateStack.back().set.insert(e); + } + } else if (currInfixOp == "-") { + for (const auto &e : set) { + expressionStateStack.back().set.erase(e); + } + } else if (currInfixOp == "&") { + AlgoCompiled::PubkeySet intersection; + + for (const auto &e : set) { + if (expressionStateStack.back().set.contains(e)) intersection.insert(e); + } + + std::swap(intersection, expressionStateStack.back().set); + } + } + + + + void installSetter(std::string_view val) { + if (currSetterVar == "mods" || currSetterVar == "voters") { + if (!a.variableIndexLookup.contains(val)) throw herr("unknown variable: ", val); + auto *setPtr = &a.pubkeySets[a.variableIndexLookup[val]]; + + if (currSetterVar == "mods") a.mods = setPtr; + else if (currSetterVar == "voters") a.voters = setPtr; + } else if (currSetterVar == "threshold") { + a.threshold = std::stod(std::string(val)); + } + } + + void installFilter(std::string_view val) { + a.filters.emplace_back(std::make_unique(val), currFilterOp, currFilterArg); + } + + + + + + void loadFollowing(std::string_view pubkey, flat_hash_set &output) { + const uint64_t kind = 3; + + env.generic_foreachFull(txn, env.dbi_Event__pubkeyKind, makeKey_StringUint64Uint64(pubkey, kind, 0), "", [&](std::string_view k, std +::string_view v){ + ParsedKey_StringUint64Uint64 parsedKey(k); + + if (parsedKey.s == pubkey && parsedKey.n1 == kind) { + auto levId = lmdb::from_sv(v); + auto ev = lookupEventByLevId(txn, levId); + + for (const auto &tagPair : *(ev.flat_nested()->tagsFixed32())) { + if ((char)tagPair->key() != 'p') continue; + output.insert(std::string(sv(tagPair->val()))); + } + } + + return false; + }); + } +}; + + + + +namespace pegtl = TAO_PEGTL_NAMESPACE; + +namespace algo_parser { + // Whitespace + + struct comment : + pegtl::seq< + pegtl::one< '#' >, + pegtl::until< pegtl::eolf > + > {}; + + struct ws : pegtl::sor< pegtl::space, comment > {}; + + template< typename R > + struct pad : pegtl::pad< R, ws > {}; + + + // Pubkeys + + struct npub : + pegtl::seq< + pegtl::string< 'n', 'p', 'u', 'b', '1' >, + pegtl::plus< pegtl::alnum > + > {}; + + struct pubkey : + pegtl::sor< + npub, + pegtl::identifier + > {}; + + struct pubkeySetOp : pegtl::one< '+', '-', '&' > {}; + + struct pubkeyGroup; + struct pubkeyList : pegtl::list< pubkeyGroup, pubkeySetOp, ws > {}; + + struct pubkeyGroupOpen : pegtl::one< '(' > {}; + struct pubkeyGroupClose : pegtl::one< ')' > {}; + + struct pubkeyModifier : pegtl::identifier {}; + struct pubkeyExpression : pegtl::seq< + pubkey, + pegtl::star< pegtl::seq< pegtl::one< '.'>, pubkeyModifier > > + > {}; + + struct pubkeyGroup : pegtl::sor< + pubkeyExpression, + pegtl::seq< + pad< pubkeyGroupOpen >, + pubkeyList, + pad< pubkeyGroupClose > + > + > {}; + + + + // Let statements + + struct variableIdentifier : pegtl::seq< pegtl::not_at< npub >, pegtl::identifier > {}; + + struct letDefinition : variableIdentifier {}; + struct letTerminator : pegtl::one< ';' > {}; + + struct let : + pegtl::seq< + pad< TAO_PEGTL_STRING("let") >, + pad< letDefinition >, + pad< pegtl::one< '=' > >, + pad< pubkeyList >, + letTerminator + > {}; + + + + + // Posts block + + struct number : + pegtl::if_then_else< pegtl::one< '.' >, + pegtl::plus< pegtl::digit >, + pegtl::seq< + pegtl::plus< pegtl::digit >, + pegtl::opt< pegtl::one< '.' >, pegtl::star< pegtl::digit > > + > + > {}; + + struct arithOp : pegtl::one< '+', '-', '*', '/' > {}; + struct arithNumber : number {}; + struct arith : + pegtl::seq< + pad< arithOp >, + arithNumber + > {}; + + struct regexpPayload : pegtl::star< pegtl::sor< pegtl::string< '\\', '/' >, pegtl::not_one< '/' > > > {}; + struct regexp : + pegtl::seq< + pegtl::one< '/' >, + regexpPayload, + pegtl::one< '/' > + > {}; + + struct contentCondition : + pegtl::seq< + pad< pegtl::one< '~' > >, + pad< regexp > + > {}; + + struct condition : + pegtl::sor< + pad< contentCondition > + > {}; + + struct setterVar : + pegtl::sor< + TAO_PEGTL_STRING("mods"), + TAO_PEGTL_STRING("voters"), + TAO_PEGTL_STRING("threshold") + > {}; + + struct setterValue : + pegtl::star< + pegtl::sor< + pegtl::alnum, + pegtl::one< '.' > + > + > {}; + + struct setterStatement : + pegtl::seq< + pad< setterVar >, + pad< TAO_PEGTL_STRING("=") >, + pad< setterValue >, + pegtl::one< ';' > + > {}; + + struct filterStatment : + pegtl::seq< + pad< arith >, + pad< TAO_PEGTL_STRING("if") >, + pad< condition >, + pegtl::one< ';' > + > {}; + + struct postBlock : + pegtl::seq< + pad< TAO_PEGTL_STRING("posts") >, + pad< pegtl::one< '{' > >, + pegtl::star< pad< pegtl::sor< setterStatement, filterStatment > > >, + pegtl::one< '}' > + > {}; + + + + + // Main + + struct anything : pegtl::sor< ws, let, postBlock > {}; + struct main : pegtl::until< pegtl::eof, pegtl::must< anything > > {}; + + + + template< typename Rule > + struct action {}; + + + template<> struct action< letDefinition > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.letStart(in.string_view()); + } + }; + + template<> struct action< letTerminator > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.letEnd(); + } + }; + + template<> struct action< pubkey > { template< typename ActionInput > + static void apply(const ActionInput& in, AlgoParseState &a) { + a.currPubkeyDesc = in.string(); + } + }; + + template<> struct action< pubkeyModifier > { template< typename ActionInput > + static void apply(const ActionInput& in, AlgoParseState &a) { + a.currModifiers.push_back(in.string()); + } + }; + + template<> struct action< pubkeySetOp > { template< typename ActionInput > + static void apply(const ActionInput& in, AlgoParseState &a) { + a.expressionStateStack.back().currInfixOp = in.string(); + } + }; + + template<> struct action< pubkeyExpression > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.letAddExpression(); + } + }; + + template<> struct action< pubkeyGroupOpen > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.expressionStateStack.push_back({ "+" }); + } + }; + + template<> struct action< pubkeyGroupClose > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + auto set = std::move(a.expressionStateStack.back().set); + a.expressionStateStack.pop_back(); + a.mergeInfix(set); + } + }; + + + + template<> struct action< setterVar > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.currSetterVar = in.string(); + } + }; + + template<> struct action< setterValue > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.installSetter(in.string_view()); + } + }; + + template<> struct action< arithOp > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.currFilterOp = in.string_view().at(0); + } + }; + + template<> struct action< arithNumber > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.currFilterArg = std::stod(in.string()); + } + }; + + template<> struct action< regexpPayload > { template< typename ActionInput > + static void apply(const ActionInput &in, AlgoParseState &a) { + a.installFilter(in.string_view()); + } + }; +} + + +inline AlgoCompiled parseAlgo(lmdb::txn &txn, std::string_view algoText) { + AlgoParseState a(txn); + + pegtl::memory_input in(algoText, ""); + + if (!pegtl::parse< algo_parser::main, algo_parser::action >(in, a)) { + throw herr("algo parse error"); + } + + return std::move(a.a); +} diff --git a/src/apps/web/AlgoScanner.h b/src/apps/web/AlgoScanner.h new file mode 100644 index 0000000..6ec4583 --- /dev/null +++ b/src/apps/web/AlgoScanner.h @@ -0,0 +1,110 @@ +#pragma once + +#include "golpe.h" + +#include "events.h" + +#include "AlgoParser.h" + + + +struct AlgoScanner { + struct EventInfo { + uint64_t comments = 0; + double score = 0.0; + }; + + struct FilteredEvent { + uint64_t levId; + std::string id; + + EventInfo info; + }; + + AlgoCompiled a; + + + AlgoScanner(lmdb::txn &txn, std::string_view algoText) : a(parseAlgo(txn, algoText)) { + } + + + std::vector getEvents(lmdb::txn &txn, Decompressor &decomp, uint64_t limit) { + flat_hash_map eventInfoCache; + std::vector output; + + env.generic_foreachFull(txn, env.dbi_Event__created_at, lmdb::to_sv(MAX_U64), lmdb::to_sv(MAX_U64), [&](auto k, auto v) { + if (output.size() > limit) return false; + + auto ev = lookupEventByLevId(txn, lmdb::from_sv(v)); + auto kind = ev.flat_nested()->kind(); + auto id = sv(ev.flat_nested()->id()); + + if (kind == 1) { + auto pubkey = std::string(sv(ev.flat_nested()->pubkey())); + + bool foundETag = false; + for (const auto &tagPair : *(ev.flat_nested()->tagsFixed32())) { + if ((char)tagPair->key() == 'e') { + auto tagEventId = std::string(sv(tagPair->val())); + eventInfoCache.emplace(tagEventId, EventInfo{}); + eventInfoCache[tagEventId].comments++; + foundETag = true; + } + } + if (foundETag) return true; // not root event + + eventInfoCache.emplace(id, EventInfo{}); + auto &eventInfo = eventInfoCache[id]; + + if (a.voters && !a.voters->contains(pubkey)) return true; + a.updateScore(txn, decomp, ev, eventInfo.score); + if (eventInfo.score < a.threshold) return true; + + output.emplace_back(FilteredEvent{ev.primaryKeyId, std::string(id), eventInfo}); + } else if (kind == 7) { + auto pubkey = std::string(sv(ev.flat_nested()->pubkey())); + //if (a.voters && !a.voters->contains(pubkey)) return true; + + const auto &tagsArr = *(ev.flat_nested()->tagsFixed32()); + for (auto it = tagsArr.rbegin(); it != tagsArr.rend(); ++it) { + auto tagPair = *it; + if ((char)tagPair->key() == 'e') { + auto tagEventId = std::string(sv(tagPair->val())); + eventInfoCache.emplace(tagEventId, EventInfo{}); + eventInfoCache[tagEventId].score++; + break; + } + } + } + + return true; + }, true); + + //for (auto &o : output) { + //} + + return output; + } + + + void loadFollowing(lmdb::txn &txn, std::string_view pubkey, flat_hash_set &output) { + const uint64_t kind = 3; + + env.generic_foreachFull(txn, env.dbi_Event__pubkeyKind, makeKey_StringUint64Uint64(pubkey, kind, 0), "", [&](std::string_view k, std +::string_view v){ + ParsedKey_StringUint64Uint64 parsedKey(k); + + if (parsedKey.s == pubkey && parsedKey.n1 == kind) { + auto levId = lmdb::from_sv(v); + auto ev = lookupEventByLevId(txn, levId); + + for (const auto &tagPair : *(ev.flat_nested()->tagsFixed32())) { + if ((char)tagPair->key() != 'p') continue; + output.insert(std::string(sv(tagPair->val()))); + } + } + + return false; + }); + } +}; diff --git a/src/apps/web/Bech32Utils.h b/src/apps/web/Bech32Utils.h new file mode 100644 index 0000000..3f8e159 --- /dev/null +++ b/src/apps/web/Bech32Utils.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "bech32.h" + + +/** Convert from one power-of-2 number base to another. */ +template +bool convertbits(std::vector& out, const std::vector& in) { + int acc = 0; + int bits = 0; + const int maxv = (1 << tobits) - 1; + const int max_acc = (1 << (frombits + tobits - 1)) - 1; + for (size_t i = 0; i < in.size(); ++i) { + int value = in[i]; + acc = ((acc << frombits) | value) & max_acc; + bits += frombits; + while (bits >= tobits) { + bits -= tobits; + out.push_back((acc >> bits) & maxv); + } + } + if (pad) { + if (bits) out.push_back((acc << (tobits - bits)) & maxv); + } else if (bits >= frombits || ((acc << (tobits - bits)) & maxv)) { + return false; + } + return true; +} + +inline std::string encodeBech32Simple(const std::string &hrp, std::string_view v) { + if (v.size() != 32) throw herr("expected bech32 argument to be 32 bytes"); + + std::vector values(32, '\0'); + memcpy(values.data(), v.data(), 32); + + std::vector values5; + convertbits<8, 5, true>(values5, values); + + return bech32::encode(hrp, values5, bech32::Encoding::BECH32); +} + +inline std::string decodeBech32Simple(std::string_view v) { + auto res = bech32::decode(std::string(v)); + + if (res.encoding == bech32::Encoding::INVALID) throw herr("invalid bech32"); + else if (res.encoding == bech32::Encoding::BECH32M) throw herr("got bech32m"); + + std::vector out; + if (!convertbits<5, 8, false>(out, res.data)) throw herr("convertbits failed"); + if (out.size() != 32) throw herr("unexpected size from bech32"); + + return std::string((char*)out.data(), out.size()); +} diff --git a/src/apps/web/HTTP.h b/src/apps/web/HTTP.h new file mode 100644 index 0000000..cb42dbf --- /dev/null +++ b/src/apps/web/HTTP.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include + + + +struct HTTPRequest : NonCopyable { + uint64_t connId; + uWS::HttpResponse *res; + + std::string ipAddr; + std::string url; + uWS::HttpMethod method = uWS::HttpMethod::METHOD_INVALID; + bool acceptGzip = false; + + std::string body; + + HTTPRequest(uint64_t connId, uWS::HttpResponse *res, uWS::HttpRequest req) : connId(connId), res(res) { + res->hasHead = true; // We'll be sending our own headers + + ipAddr = res->httpSocket->getAddressBytes(); + method = req.getMethod(); + url = req.getUrl().toString(); + acceptGzip = req.getHeader("accept-encoding").toStringView().find("gzip") != std::string::npos; + } +}; + + +struct HTTPResponse : NonCopyable { + std::string_view code = "200 OK"; + std::string_view contentType = "text/html; charset=utf-8"; + std::string extraHeaders; + std::string body; + + std::string eTag() { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(body.data()), body.size(), hash); + return to_hex(std::string_view(reinterpret_cast(hash), SHA256_DIGEST_LENGTH/2)); + } + + std::string encode(bool doCompress) { + std::string compressed; + bool didCompress = false; + + if (doCompress) { + compressed.resize(body.size()); + + z_stream zs; + zs.zalloc = Z_NULL; + zs.zfree = Z_NULL; + zs.opaque = Z_NULL; + zs.avail_in = body.size(); + zs.next_in = (Bytef*)body.data(); + zs.avail_out = compressed.size(); + zs.next_out = (Bytef*)compressed.data(); + + deflateInit2(&zs, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY); + auto ret1 = deflate(&zs, Z_FINISH); + auto ret2 = deflateEnd(&zs); + + if (ret1 == Z_STREAM_END && ret2 == Z_OK) { + compressed.resize(zs.total_out); + didCompress = true; + } + } + + auto bodySize = didCompress ? compressed.size() : body.size(); + + std::string output; + output.reserve(bodySize + 1024); + + output += "HTTP/1.1 "; + output += code; + output += "\r\nContent-Length: "; + output += std::to_string(bodySize); + output += "\r\nContent-Type: "; + output += contentType; + output += "\r\n"; + if (didCompress) output += "Content-Encoding: gzip\r\nVary: Accept-Encoding\r\n"; + output += extraHeaders; + output += "Connection: Keep-Alive\r\n\r\n"; + output += didCompress ? compressed : body; + + return output; + } +}; diff --git a/src/apps/web/README b/src/apps/web/README new file mode 100644 index 0000000..46edfe3 --- /dev/null +++ b/src/apps/web/README @@ -0,0 +1,11 @@ +no: + * slow loading + * burning client CPU + * wasting bandwidth + * images + * cookie warning crap + * pop-ups that come up when you scroll half way down + * re-rendering, re-flowing, bouncing around of the page after you started reading + * breaking back button + * messing with scrolling + * breaking when JS disabled diff --git a/src/apps/web/TODO b/src/apps/web/TODO new file mode 100644 index 0000000..0c0fb9e --- /dev/null +++ b/src/apps/web/TODO @@ -0,0 +1,27 @@ +build + ! move static/Makefile rules into main system + ! bundle static files in strfry binary + +read + ! caching + ! non-500 error pages when bech32 fails to parse, for example + ! search field: enter anything, pubkey (hex or npub), eventId, etc. maybe even full-text search? + ! rss + ! click on up/down vote counts to see who voted + ? pagination on user comments screen, communities + +styling + ! make URLs in user profiles clickable + ! make nostr:note1... refs clickable + ! mobile + ! favicon + ! make login/upvote/etc buttons do something when not logged in/no nostr plugin installed + ? smarter truncation/abbrev (try to cut at a sentence, or word boundary, don't cut URLs) + ? abbrev comments should be smaller? non-pre tag? + ! limit name length: http://localhost:8080/u/npub1e44hczvjqvnp5shx9tmapmw5w6q9x98wdrd2lxhmwfsdmuf5gp9qwvpk6g + +misc + nip-05 checkmarks + +write + ? edit profile (or maybe just send them to https://metadata.nostr.com/) diff --git a/src/apps/web/WebData.h b/src/apps/web/WebData.h new file mode 100644 index 0000000..5a95544 --- /dev/null +++ b/src/apps/web/WebData.h @@ -0,0 +1,676 @@ +#pragma once + +#include "re2/re2.h" + +#include "Bech32Utils.h" +#include "WebUtils.h" +#include "AlgoScanner.h" +#include "WebTemplates.h" +#include "DBQuery.h" + + + + +std::string stripUrls(std::string &content); + + + + +struct User { + std::string pubkey; + + std::string npubId; + std::string username; + std::optional kind0Json; + std::optional kind3Event; + + User(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) : pubkey(pubkey) { + npubId = encodeBech32Simple("npub", pubkey); + + kind0Json = loadKindJson(txn, decomp, 0); + + try { + if (kind0Json) username = kind0Json->at("name").get_string(); + } catch (std::exception &e) { + } + + if (username.size() == 0) username = to_hex(pubkey.substr(0,4)); + if (username.size() > 50) username = username.substr(0, 50) + "..."; + } + + std::optional loadKindJson(lmdb::txn &txn, Decompressor &decomp, uint64_t kind) { + std::optional output; + + env.generic_foreachFull(txn, env.dbi_Event__pubkeyKind, makeKey_StringUint64Uint64(pubkey, kind, 0), "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64Uint64 parsedKey(k); + + if (parsedKey.s == pubkey && parsedKey.n1 == kind) { + auto levId = lmdb::from_sv(v); + tao::json::value json = tao::json::from_string(getEventJson(txn, decomp, levId)); + + try { + output = tao::json::from_string(json.at("content").get_string()); + if (!output->is_object()) output = std::nullopt; + } catch (std::exception &e) { + } + } + + return false; + }); + + return output; + } + + std::optional loadKindEvent(lmdb::txn &txn, Decompressor &decomp, uint64_t kind) { + std::optional output; + + env.generic_foreachFull(txn, env.dbi_Event__pubkeyKind, makeKey_StringUint64Uint64(pubkey, kind, 0), "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64Uint64 parsedKey(k); + + if (parsedKey.s == pubkey && parsedKey.n1 == kind) { + auto levId = lmdb::from_sv(v); + output = tao::json::from_string(getEventJson(txn, decomp, levId)); + } + + return false; + }); + + return output; + } + + bool kind0Found() const { + return !!kind0Json; + } + + std::string getMeta(std::string_view field) const { + if (!kind0Json) throw herr("can't getMeta because user doesn't have kind 0"); + if (kind0Json->get_object().contains(field) && kind0Json->at(field).is_string()) return kind0Json->at(field).get_string(); + return ""; + } + + void populateContactList(lmdb::txn &txn, Decompressor &decomp) { + kind3Event = loadKindEvent(txn, decomp, 3); + } + + std::vector getFollowers(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) { + std::vector output; + flat_hash_set alreadySeen; + + std::string prefix = "p"; + prefix += pubkey; + + env.generic_foreachFull(txn, env.dbi_Event__tag, prefix, "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64 parsedKey(k); + if (parsedKey.s != prefix) return false; + + auto levId = lmdb::from_sv(v); + auto ev = lookupEventByLevId(txn, levId); + + if (ev.flat_nested()->kind() == 3) { + auto pubkey = std::string(sv(ev.flat_nested()->pubkey())); + + if (!alreadySeen.contains(pubkey)) { + alreadySeen.insert(pubkey); + output.emplace_back(std::move(pubkey)); + } + } + + return true; + }); + + return output; + } +}; + +struct UserCache { + std::unordered_map cache; + + const User *getUser(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) { + auto u = cache.find(pubkey); + if (u != cache.end()) return &u->second; + + cache.emplace(pubkey, User(txn, decomp, pubkey)); + return &cache.at(pubkey); + } +}; + + +struct Event { + defaultDb::environment::View_Event ev; + + tao::json::value json = tao::json::null; + std::string parent; + std::string root; + + uint64_t upVotes = 0; + uint64_t downVotes = 0; + + + Event(defaultDb::environment::View_Event ev) : ev(ev) { + } + + static Event fromLevId(lmdb::txn &txn, uint64_t levId) { + return Event(lookupEventByLevId(txn, levId)); + } + + static Event fromId(lmdb::txn &txn, std::string_view id) { + auto existing = lookupEventById(txn, id); + if (!existing) throw herr("unable to find event"); + return Event(std::move(*existing)); + } + + static Event fromIdExternal(lmdb::txn &txn, std::string_view id) { + if (id.starts_with("note1")) { + return fromId(txn, decodeBech32Simple(id)); + } else { + return fromId(txn, from_hex(id)); + } + } + + + std::string getId() const { + return std::string(sv(ev.flat_nested()->id())); + } + + uint64_t getKind() const { + return ev.flat_nested()->kind(); + } + + uint64_t getCreatedAt() const { + return ev.flat_nested()->created_at(); + } + + std::string getPubkey() const { + return std::string(sv(ev.flat_nested()->pubkey())); + } + + std::string getNoteId() const { + return encodeBech32Simple("note", getId()); + } + + std::string getParentNoteId() const { + return encodeBech32Simple("note", parent); + } + + std::string getRootNoteId() const { + return encodeBech32Simple("note", root); + } + + // FIXME: Use "subject" tag if present? + // FIXME: Don't truncate UTF-8 mid-sequence + // FIXME: Don't put ellipsis if truncated text ends in punctuation + + std::string summaryHtml() const { + std::string content = json.at("content").get_string(); + auto firstUrl = stripUrls(content); + + auto textAbbrev = [](std::string &str, size_t maxLen){ + if (str.size() > maxLen) str = str.substr(0, maxLen-3) + "..."; + }; + + textAbbrev(content, 100); + templarInternal::htmlEscape(content, true); + + if (firstUrl.size()) { + while (content.size() && isspace(content.back())) content.pop_back(); + if (content.empty()) { + content = firstUrl; + textAbbrev(content, 100); + templarInternal::htmlEscape(content, true); + } + + return std::string("" + content + ""; + } + + return content; + } + + + void populateJson(lmdb::txn &txn, Decompressor &decomp) { + if (!json.is_null()) return; + + json = tao::json::from_string(getEventJson(txn, decomp, ev.primaryKeyId)); + } + + void populateRootParent(lmdb::txn &txn, Decompressor &decomp) { + populateJson(txn, decomp); + + const auto &tags = json.at("tags").get_array(); + + // Try to find a e-tags with root/reply types + for (const auto &t : tags) { + const auto &tArr = t.get_array(); + if (tArr.at(0) == "e" && tArr.size() >= 4 && tArr.at(3) == "root") { + root = from_hex(tArr.at(1).get_string()); + } else if (tArr.at(0) == "e" && tArr.size() >= 4 && tArr.at(3) == "reply") { + parent = from_hex(tArr.at(1).get_string()); + } + } + + if (!root.size()) { + // Otherwise, assume first e tag is root + + for (auto it = tags.begin(); it != tags.end(); ++it) { + const auto &tArr = it->get_array(); + if (tArr.at(0) == "e") { + root = from_hex(tArr.at(1).get_string()); + break; + } + } + } + + if (!parent.size()) { + // Otherwise, assume last e tag is root + + for (auto it = tags.rbegin(); it != tags.rend(); ++it) { + const auto &tArr = it->get_array(); + if (tArr.at(0) == "e") { + parent = from_hex(tArr.at(1).get_string()); + break; + } + } + } + } +}; + + + +inline void preprocessContent(lmdb::txn &txn, Decompressor &decomp, const Event &ev, UserCache &userCache, std::string &content) { + static RE2 matcher(R"((?is)(.*?)(https?://\S+|#\[\d+\]))"); + + std::string output; + + std::string_view contentSv(content); + re2::StringPiece input(contentSv); + re2::StringPiece prefix, match; + + auto sv = [](re2::StringPiece s){ return std::string_view(s.data(), s.size()); }; + auto appendLink = [&](std::string_view url, std::string_view text){ + output += ""; + output += text; + output += ""; + }; + + while (RE2::Consume(&input, matcher, &prefix, &match)) { + output += sv(prefix); + + if (match.starts_with("http")) { + appendLink(sv(match), sv(match)); + } else if (match.starts_with("#[")) { + bool didTransform = false; + auto offset = std::stoull(std::string(sv(match)).substr(2, match.size() - 3)); + + const auto &tags = ev.json.at("tags").get_array(); + + try { + const auto &tag = tags.at(offset).get_array(); + + if (tag.at(0) == "p") { + const auto *u = userCache.getUser(txn, decomp, from_hex(tag.at(1).get_string())); + appendLink(std::string("/u/") + u->npubId, u->username); + didTransform = true; + } else if (tag.at(0) == "e") { + appendLink(std::string("/e/") + encodeBech32Simple("note", from_hex(tag.at(1).get_string())), sv(match)); + didTransform = true; + } + } catch(std::exception &e) { + //LW << "tag parse error: " << e.what(); + } + + if (!didTransform) output += sv(match); + } + } + + if (output.size()) { + output += std::string_view(input.data(), input.size()); + std::swap(output, content); + } +} + + +inline std::string stripUrls(std::string &content) { + static RE2 matcher(R"((?is)(.*?)(https?://\S+))"); + + std::string output; + std::string firstUrl; + + std::string_view contentSv(content); + re2::StringPiece input(contentSv); + re2::StringPiece prefix, match; + + auto sv = [](re2::StringPiece s){ return std::string_view(s.data(), s.size()); }; + + while (RE2::Consume(&input, matcher, &prefix, &match)) { + output += sv(prefix); + + if (firstUrl.empty()) { + firstUrl = std::string(sv(match)); + } + } + + output += std::string_view(input.data(), input.size()); + + std::swap(output, content); + return firstUrl; +} + + + + +struct EventThread { + std::string rootEventId; + bool isRootEventThreadRoot; + flat_hash_map eventCache; + + flat_hash_map> children; // parentEventId -> childEventIds + std::string pubkeyHighlight; + bool isFullThreadLoaded = false; + + + // Load all events under an eventId + + EventThread(std::string rootEventId, bool isRootEventThreadRoot, flat_hash_map &&eventCache) + : rootEventId(rootEventId), isRootEventThreadRoot(isRootEventThreadRoot), eventCache(eventCache) {} + + EventThread(lmdb::txn &txn, Decompressor &decomp, std::string_view id_) : rootEventId(std::string(id_)) { + try { + eventCache.emplace(rootEventId, Event::fromId(txn, rootEventId)); + } catch (std::exception &e) { + return; + } + + + eventCache.at(rootEventId).populateRootParent(txn, decomp); + isRootEventThreadRoot = eventCache.at(rootEventId).root.empty(); + isFullThreadLoaded = true; + + + std::vector pendingQueue; + pendingQueue.emplace_back(rootEventId); + + while (pendingQueue.size()) { + auto currId = std::move(pendingQueue.back()); + pendingQueue.pop_back(); + + std::string prefix = "e"; + prefix += currId; + + env.generic_foreachFull(txn, env.dbi_Event__tag, prefix, "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64 parsedKey(k); + if (parsedKey.s != prefix) return false; + + auto levId = lmdb::from_sv(v); + Event e = Event::fromLevId(txn, levId); + std::string childEventId = e.getId(); + + if (eventCache.contains(childEventId)) return true; + + eventCache.emplace(childEventId, std::move(e)); + if (!isRootEventThreadRoot) pendingQueue.emplace_back(childEventId); + + return true; + }); + } + + for (auto &[id, e] : eventCache) { + e.populateRootParent(txn, decomp); + + auto kind = e.getKind(); + + if (e.parent.size()) { + if (kind == 1) { + if (!children.contains(e.parent)) children.emplace(std::piecewise_construct, std::make_tuple(e.parent), std::make_tuple()); + children.at(e.parent).insert(id); + } else if (kind == 7) { + auto p = eventCache.find(e.parent); + if (p != eventCache.end()) { + auto &parent = p->second; + + if (e.json.at("content").get_string() == "-") { + parent.downVotes++; + } else { + parent.upVotes++; + } + } + } + } + } + } + + + TemplarResult render(lmdb::txn &txn, Decompressor &decomp, UserCache &userCache, std::optional focusOnPubkey = std::nullopt) { + auto now = hoytech::curr_time_s(); + flat_hash_set processedLevIds; + + struct Reply { + uint64_t timestamp; + TemplarResult rendered; + }; + + struct RenderedEvent { + std::string content; + std::string timestamp; + const Event *ev = nullptr; + const User *user = nullptr; + bool isFullThreadLoaded = false; + bool eventPresent = true; + bool abbrev = false; + bool highlight = false; + std::vector replies; + }; + + std::function process = [&](const std::string &id){ + RenderedEvent ctx; + + auto p = eventCache.find(id); + if (p != eventCache.end()) { + const auto &elem = p->second; + processedLevIds.insert(elem.ev.primaryKeyId); + + auto pubkey = elem.getPubkey(); + + ctx.timestamp = renderTimestamp(now, elem.getCreatedAt()); + ctx.user = userCache.getUser(txn, decomp, elem.getPubkey()); + ctx.isFullThreadLoaded = isFullThreadLoaded; + ctx.eventPresent = true; + ctx.highlight = (pubkey == pubkeyHighlight); + + ctx.abbrev = focusOnPubkey && *focusOnPubkey != pubkey; + if (ctx.abbrev) { + ctx.content = elem.summaryHtml(); + } else { + ctx.content = templarInternal::htmlEscape(elem.json.at("content").get_string(), false); + preprocessContent(txn, decomp, elem, userCache, ctx.content); + } + + ctx.ev = &elem; + } else { + ctx.eventPresent = false; + } + + if (children.contains(id)) { + for (const auto &childId : children.at(id)) { + auto timestamp = MAX_U64; + auto p = eventCache.find(childId); + if (p != eventCache.end()) timestamp = p->second.getCreatedAt(); + + ctx.replies.emplace_back(timestamp, process(childId)); + } + + std::sort(ctx.replies.begin(), ctx.replies.end(), [](auto &a, auto &b){ return a.timestamp < b.timestamp; }); + } + + return tmpl::event::event(ctx); + }; + + + struct { + TemplarResult foundEvents; + std::vector orphanNodes; + } ctx; + + ctx.foundEvents = process(rootEventId); + + for (auto &[id, e] : eventCache) { + if (processedLevIds.contains(e.ev.primaryKeyId)) continue; + if (e.getKind() != 1) continue; + + ctx.orphanNodes.emplace_back(e.getCreatedAt(), process(id)); + } + + std::sort(ctx.orphanNodes.begin(), ctx.orphanNodes.end(), [](auto &a, auto &b){ return a.timestamp < b.timestamp; }); + + return tmpl::events(ctx); + } +}; + + + +struct UserEvents { + User u; + + struct EventCluster { + std::string rootEventId; + flat_hash_map eventCache; // eventId (non-root) -> Event + bool isRootEventFromUser = false; + bool isRootPresent = false; + uint64_t rootEventTimestamp = 0; + + EventCluster(std::string rootEventId) : rootEventId(rootEventId) {} + }; + + std::vector eventClusterArr; + + UserEvents(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) : u(txn, decomp, pubkey) { + flat_hash_map eventClusters; // eventId (root) -> EventCluster + + env.generic_foreachFull(txn, env.dbi_Event__pubkeyKind, makeKey_StringUint64Uint64(pubkey, 1, MAX_U64), "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64Uint64 parsedKey(k); + if (parsedKey.s != pubkey || parsedKey.n1 != 1) return false; + + Event ev = Event::fromLevId(txn, lmdb::from_sv(v)); + ev.populateRootParent(txn, decomp); + auto id = ev.getId(); + + auto installRoot = [&](std::string rootId, Event &&rootEvent){ + rootEvent.populateRootParent(txn, decomp); + + eventClusters.emplace(rootId, rootId); + auto &cluster = eventClusters.at(rootId); + + cluster.isRootPresent = true; + cluster.isRootEventFromUser = rootEvent.getPubkey() == u.pubkey; + cluster.rootEventTimestamp = rootEvent.getCreatedAt(); + cluster.eventCache.emplace(rootId, std::move(rootEvent)); + }; + + if (ev.root.size()) { + // Event is not root + + if (!eventClusters.contains(ev.root)) { + try { + installRoot(ev.root, Event::fromId(txn, ev.root)); + } catch (std::exception &e) { + // no root event + eventClusters.emplace(ev.root, ev.root); + auto &cluster = eventClusters.at(ev.root); + + cluster.isRootPresent = true; + } + } + + eventClusters.at(ev.root).eventCache.emplace(id, std::move(ev)); + } else { + // Event is root + + if (!eventClusters.contains(ev.root)) { + installRoot(id, std::move(ev)); + } + } + + return true; + }, true); + + for (auto &[k, v] : eventClusters) { + eventClusterArr.emplace_back(std::move(v)); + } + + std::sort(eventClusterArr.begin(), eventClusterArr.end(), [](auto &a, auto &b){ return b.rootEventTimestamp < a.rootEventTimestamp; }); + } + + TemplarResult render(lmdb::txn &txn, Decompressor &decomp) { + std::vector renderedThreads; + UserCache userCache; + + for (auto &cluster : eventClusterArr) { + EventThread eventThread(cluster.rootEventId, cluster.isRootEventFromUser, std::move(cluster.eventCache)); + eventThread.pubkeyHighlight = u.pubkey; + renderedThreads.emplace_back(eventThread.render(txn, decomp, userCache, u.pubkey)); + } + + struct { + std::vector &renderedThreads; + User &u; + } ctx = { + renderedThreads, + u, + }; + + return tmpl::user::comments(ctx); + } +}; + + + +struct CommunitySpec { + bool valid = true; + tao::json::value raw; + + std::string name; + std::string desc; + std::string algo; + + std::string adminNpub; + std::string adminUsername; + std::string adminTopic; +}; + +inline CommunitySpec lookupCommunitySpec(lmdb::txn &txn, Decompressor &decomp, UserCache &userCache, std::string_view algoDescriptor) { + CommunitySpec spec; + + size_t pos = algoDescriptor.find("/"); + if (pos == std::string_view::npos) throw herr("bad algo descriptor"); + spec.adminNpub = std::string(algoDescriptor.substr(0, pos)); + std::string authorPubkey = decodeBech32Simple(spec.adminNpub); + spec.adminTopic = algoDescriptor.substr(pos + 1); + + tao::json::value filter = tao::json::value({ + { "authors", tao::json::value::array({ to_hex(authorPubkey) }) }, + { "kinds", tao::json::value::array({ uint64_t(33700) }) }, + { "#d", tao::json::value::array({ spec.adminTopic }) }, + }); + + bool found = false; + + foreachByFilter(txn, filter, [&](uint64_t levId){ + tao::json::value ev = tao::json::from_string(getEventJson(txn, decomp, levId)); + spec.algo = ev.at("content").get_string(); + found = true; + return false; + }); + + if (!found) throw herr("unable to find algo"); + + spec.raw = tao::json::from_string(spec.algo); + + spec.name = spec.raw.at("name").get_string(); + spec.desc = spec.raw.at("desc").get_string(); + spec.algo = spec.raw.at("algo").get_string(); + + auto *user = userCache.getUser(txn, decomp, authorPubkey); + spec.adminUsername = user->username; + + return spec; +} diff --git a/src/apps/web/WebHttpsocket.cpp b/src/apps/web/WebHttpsocket.cpp new file mode 100644 index 0000000..1cb4bdd --- /dev/null +++ b/src/apps/web/WebHttpsocket.cpp @@ -0,0 +1,148 @@ +#include + +#include "WebServer.h" + +#include "app_git_version.h" + + + +void WebServer::runHttpsocket(ThreadPool::Thread &thr) { + uWS::Hub hub; + uWS::Group *hubGroup; + flat_hash_map connIdToConnection; + uint64_t nextConnectionId = 1; + + flat_hash_map receivingRequests; + + std::vector tpReaderLock(tpReader.numThreads, false); + std::queue pendingReaderMessages; + + + { + int extensionOptions = 0; + + hubGroup = hub.createGroup(extensionOptions); + } + + + hubGroup->onHttpConnection([&](uWS::HttpSocket *hs) { + uint64_t connId = nextConnectionId++; + Connection *c = new Connection(hs, connId); + + hs->setUserData((void*)c); + connIdToConnection.emplace(connId, c); + }); + + hubGroup->onHttpDisconnection([&](uWS::HttpSocket *hs) { + auto *c = (Connection*)hs->getUserData(); + + connIdToConnection.erase(c->connId); + delete c; + }); + + hubGroup->onHttpRequest([&](uWS::HttpResponse *res, uWS::HttpRequest reqRaw, char *data, size_t length, size_t remainingBytes){ + auto *c = (Connection*)res->httpSocket->getUserData(); + + HTTPRequest req(c->connId, res, reqRaw); + req.body = std::string(data, length); + + c->pendingRequests.insert(res); + + if (req.method == uWS::HttpMethod::METHOD_GET) { + auto m = MsgWebReader{MsgWebReader::Request{std::move(req), MAX_U64}}; + bool didDispatch = false; + + for (uint64_t i = 0; i < tpReader.numThreads; i++) { + if (tpReaderLock[i] == false) { + tpReaderLock[i] = true; + std::get(m.msg).lockedThreadId = i; + tpReader.dispatch(i, std::move(m)); + didDispatch = true; + break; + } + } + + if (!didDispatch) pendingReaderMessages.emplace(std::move(m)); + } else if (req.method == uWS::HttpMethod::METHOD_POST) { + if (remainingBytes) { + receivingRequests.emplace(res, std::move(req)); + } else { + tpWriter.dispatch(0, MsgWebWriter{MsgWebWriter::Request{std::move(req)}}); + } + } else { + sendHttpResponse(req, "Method Not Allowed", "405 Method Not Allowed"); + } + }); + + hubGroup->onHttpData([&](uWS::HttpResponse *res, char *data, size_t length, size_t remainingBytes){ + auto &req = receivingRequests.at(res); + + req.body += std::string_view(data, length); + + if (remainingBytes) { + auto m = MsgWebWriter{MsgWebWriter::Request{std::move(req)}}; + receivingRequests.erase(res); + tpWriter.dispatch(0, std::move(m)); + } + }); + + + auto unlockThread = [&](uint64_t lockedThreadId){ + if (lockedThreadId == MAX_U64) return; + + if (tpReaderLock.at(lockedThreadId) == false) throw herr("tried to unlock already unlocked reader lock!"); + + if (pendingReaderMessages.empty()) { + tpReaderLock[lockedThreadId] = false; + } else { + std::get(pendingReaderMessages.front().msg).lockedThreadId = lockedThreadId; + tpReader.dispatch(lockedThreadId, std::move(pendingReaderMessages.front())); + pendingReaderMessages.pop(); + } + }; + + std::function asyncCb = [&]{ + auto newMsgs = thr.inbox.pop_all_no_wait(); + + for (auto &newMsg : newMsgs) { + if (auto msg = std::get_if(&newMsg.msg)) { + auto it = connIdToConnection.find(msg->connId); + if (it == connIdToConnection.end()) continue; + auto &c = *it->second; + + if (!c.pendingRequests.contains(msg->res)) { + LW << "Couldn't find request in pendingRequests set"; + continue; + } + + c.pendingRequests.erase(msg->res); + + msg->res->end(msg->payload.data(), msg->payload.size()); + + unlockThread(msg->lockedThreadId); + } else if (auto msg = std::get_if(&newMsg.msg)) { + unlockThread(msg->lockedThreadId); + } + } + }; + + hubTrigger = std::make_unique(hub.getLoop()); + hubTrigger->setData(&asyncCb); + + hubTrigger->start([](uS::Async *a){ + auto *r = static_cast *>(a->data); + (*r)(); + }); + + + + int port = cfg().web__port; + + std::string bindHost = cfg().web__bind; + + if (!hub.listen(bindHost.c_str(), port, nullptr, uS::REUSE_PORT, hubGroup)) throw herr("unable to listen on port ", port); + + LI << "Started http server on " << bindHost << ":" << port; + + hub.run(); +} diff --git a/src/apps/web/WebReader.cpp b/src/apps/web/WebReader.cpp new file mode 100644 index 0000000..1a0029f --- /dev/null +++ b/src/apps/web/WebReader.cpp @@ -0,0 +1,432 @@ +#include "WebServer.h" +#include "WebData.h" + + + + + +std::string exportUserEvents(lmdb::txn &txn, Decompressor &decomp, std::string_view pubkey) { + std::string output; + + env.generic_foreachFull(txn, env.dbi_Event__pubkey, makeKey_StringUint64(pubkey, MAX_U64), "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64 parsedKey(k); + if (parsedKey.s != pubkey) return false; + + uint64_t levId = lmdb::from_sv(v); + output += getEventJson(txn, decomp, levId); + output += "\n"; + + return true; + }, true); + + return output; +} + + +std::string exportEventThread(lmdb::txn &txn, Decompressor &decomp, std::string_view rootId) { + std::string output; + + { + auto rootEv = lookupEventById(txn, rootId); + if (rootEv) { + output += getEventJson(txn, decomp, rootEv->primaryKeyId); + output += "\n"; + } + } + + std::string prefix = "e"; + prefix += rootId; + + env.generic_foreachFull(txn, env.dbi_Event__tag, prefix, "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64 parsedKey(k); + if (parsedKey.s != prefix) return false; + + auto levId = lmdb::from_sv(v); + + output += getEventJson(txn, decomp, levId); + output += "\n"; + + return true; + }); + + return output; +} + +void doSearch(lmdb::txn &txn, Decompressor &decomp, std::string_view search, std::vector &results) { + auto doesPubkeyExist = [&](std::string_view pubkey){ + bool ret = false; + + env.generic_foreachFull(txn, env.dbi_Event__pubkey, makeKey_StringUint64(pubkey, 0), "", [&](std::string_view k, std::string_view v){ + ParsedKey_StringUint64 parsedKey(k); + + if (parsedKey.s == pubkey) ret = true; + + return false; + }); + + return ret; + }; + + if (search.size() == 64) { + try { + auto word = from_hex(search); + if (doesPubkeyExist(word)) results.emplace_back(tmpl::search::userResult(User(txn, decomp, word))); + } catch(...) {} + } + + if (search.starts_with("npub1")) { + try { + auto word = decodeBech32Simple(search); + if (doesPubkeyExist(word)) results.emplace_back(tmpl::search::userResult(User(txn, decomp, word))); + } catch(...) {} + } + + try { + auto e = Event::fromIdExternal(txn, search); + results.emplace_back(tmpl::search::eventResult(e)); + } catch(...) {} +} + + + + + +TemplarResult renderCommunityEvents(lmdb::txn &txn, Decompressor &decomp, UserCache &userCache, const CommunitySpec &communitySpec) { + AlgoScanner a(txn, communitySpec.algo); + auto events = a.getEvents(txn, decomp, 300); + + std::vector rendered; + auto now = hoytech::curr_time_s(); + uint64_t n = 1; + + for (auto &fe : events) { + auto ev = Event::fromLevId(txn, fe.levId); + ev.populateJson(txn, decomp); + + struct { + uint64_t n; + const Event &ev; + const User &user; + std::string timestamp; + AlgoScanner::EventInfo &info; + } ctx = { + n, + ev, + *userCache.getUser(txn, decomp, ev.getPubkey()), + renderTimestamp(now, ev.getCreatedAt()), + fe.info, + }; + + rendered.emplace_back(tmpl::community::item(ctx)); + n++; + } + + return tmpl::community::list(rendered); +} + + + + + + +HTTPResponse WebServer::generateReadResponse(lmdb::txn &txn, Decompressor &decomp, const HTTPRequest &req, uint64_t &cacheTime) { + HTTPResponse httpResp; + + auto startTime = hoytech::curr_time_us(); + Url u(req.url); + + LI << "READ REQUEST: " << req.url; + + UserCache userCache; + + std::string_view code = "200 OK"; + std::string_view contentType = "text/html; charset=utf-8"; + + // Normal frame: + + std::optional body; + std::optional communitySpec; + std::string title; + + // Or, raw: + + std::optional rawBody; + + if (u.path.size() == 0 || u.path[0] == "algo") { + communitySpec = lookupCommunitySpec(txn, decomp, userCache, cfg().web__homepageCommunity); + cacheTime = 30'000'000; + } + + if (u.path.size() == 0) { + body = renderCommunityEvents(txn, decomp, userCache, *communitySpec); + } else if (u.path[0] == "algo") { + struct { + std::string community; + const CommunitySpec &communitySpec; + std::string_view descriptor; + } ctx = { + "homepage", + *communitySpec, + cfg().web__homepageCommunity, + }; + + body = tmpl::community::communityInfo(ctx); + } else if (u.path[0] == "e") { + if (u.path.size() == 2) { + EventThread et(txn, decomp, decodeBech32Simple(u.path[1])); + body = et.render(txn, decomp, userCache); + } else if (u.path.size() == 3) { + if (u.path[2] == "raw.json") { + auto ev = Event::fromIdExternal(txn, u.path[1]); + ev.populateJson(txn, decomp); + rawBody = tao::json::to_string(ev.json, 4); + contentType = "application/json; charset=utf-8"; + } else if (u.path[2] == "export.jsonl") { + rawBody = exportEventThread(txn, decomp, decodeBech32Simple(u.path[1])); + contentType = "application/jsonl+json; charset=utf-8"; + } + } + } else if (u.path[0] == "u") { + if (u.path.size() == 2) { + User user(txn, decomp, decodeBech32Simple(u.path[1])); + title = std::string("profile: ") + user.username; + body = tmpl::user::metadata(user); + } else if (u.path.size() == 3) { + std::string userPubkey; + + if (u.path[1].starts_with("npub1")) { + userPubkey = decodeBech32Simple(u.path[1]); + } else { + userPubkey = from_hex(u.path[1]); + } + + if (u.path[2] == "notes") { + UserEvents uc(txn, decomp, userPubkey); + title = std::string("notes: ") + uc.u.username; + body = uc.render(txn, decomp); + } else if (u.path[2] == "export.jsonl") { + rawBody = exportUserEvents(txn, decomp, userPubkey); + contentType = "application/jsonl+json; charset=utf-8"; + } else if (u.path[2] == "metadata.json") { + User user(txn, decomp, userPubkey); + rawBody = user.kind0Found() ? tao::json::to_string(*user.kind0Json) : "{}"; + contentType = "application/json; charset=utf-8"; + } else if (u.path[2] == "following") { + User user(txn, decomp, userPubkey); + title = std::string("following: ") + user.username; + user.populateContactList(txn, decomp); + + struct { + User &user; + std::function getUser; + } ctx = { + user, + [&](const std::string &pubkey){ return userCache.getUser(txn, decomp, pubkey); }, + }; + + body = tmpl::user::following(ctx); + } else if (u.path[2] == "followers") { + User user(txn, decomp, userPubkey); + title = std::string("followers: ") + user.username; + auto followers = user.getFollowers(txn, decomp, user.pubkey); + + struct { + const User &user; + const std::vector &followers; + std::function getUser; + } ctx = { + user, + followers, + [&](const std::string &pubkey){ return userCache.getUser(txn, decomp, pubkey); }, + }; + + body = tmpl::user::followers(ctx); + } + } + } else if (u.path[0] == "search") { + std::vector results; + + if (u.query.starts_with("q=")) { + std::string_view search = u.query.substr(2); + + doSearch(txn, decomp, search, results); + + struct { + std::string_view search; + const std::vector &results; + } ctx = { + search, + results, + }; + + body = tmpl::searchPage(ctx); + } + } else if (u.path[0] == "post") { + body = tmpl::newPost(nullptr); + } + + + + + std::string responseData; + + if (body) { + if (title.size()) title += " | "; + + struct { + const TemplarResult &body; + const std::optional &communitySpec; + std::string_view title; + std::string staticFilesPrefix; + } ctx = { + *body, + communitySpec, + title, + "http://127.0.0.1:8081", + }; + + responseData = std::move(tmpl::main(ctx).str); + } else if (rawBody) { + responseData = std::move(*rawBody); + } else { + code = "404 Not Found"; + body = TemplarResult{ "Not found" }; + } + + httpResp.body = responseData; + httpResp.code = code; + httpResp.contentType = contentType; + + LI << "Reply: " << code << " / " << responseData.size() << " bytes in " << (hoytech::curr_time_us() - startTime) << "us"; + + return httpResp; +} + + + +void WebServer::handleReadRequest(lmdb::txn &txn, Decompressor &decomp, uint64_t lockedThreadId, HTTPRequest &req) { + auto now = hoytech::curr_time_us(); + std::string response; + bool cacheItemFound = false, preGenerate = false; + + { + CacheItem *item = nullptr; + + { + std::lock_guard guard(cacheLock); + auto it = cache.find(req.url); + if (it != cache.end()) item = it->second.get(); + } + + bool addedToPending = false; + + if (item) { + cacheItemFound = true; + std::lock_guard guard(item->lock); + + if (now < item->expiry) { + response = req.acceptGzip ? item->payloadGzip : item->payload; + if (now > item->softExpiry && !item->generationInProgress) { + preGenerate = true; + item->generationInProgress = true; + LI << "DOING PREGEN"; + } + } else { + if (item->generationInProgress) { + item->pendingRequests.emplace_back(std::move(req)); + addedToPending = true; + } + + item->generationInProgress = true; + } + } + + if (addedToPending) { + unlockThread(lockedThreadId); + return; + } + } + + if (response.size()) { + if (preGenerate) { + sendHttpResponseAndUnlock(MAX_U64, req, response); + } else { + sendHttpResponseAndUnlock(lockedThreadId, req, response); + return; + } + } + + uint64_t cacheTime = 0; + + // FIXME: try/catch + auto resp = generateReadResponse(txn, decomp, req, cacheTime); + + if (cacheTime == 0 && !cacheItemFound) { + std::string payload = resp.encode(req.acceptGzip); + sendHttpResponseAndUnlock(lockedThreadId, req, payload); + return; + } + + std::string payload = resp.encode(false); + std::string payloadGzip = resp.encode(true); + now = hoytech::curr_time_us(); + std::vector pendingRequests; + + { + CacheItem *item = nullptr; + + { + std::lock_guard guard(cacheLock); + item = cache.emplace(req.url, std::make_unique()).first->second.get(); + } + + { + std::lock_guard guard(item->lock); + + item->expiry = now + cacheTime; + item->softExpiry = now + (cacheTime/2); + + item->payload = payload; + item->payloadGzip = payloadGzip; + + item->generationInProgress = false; + std::swap(item->pendingRequests, pendingRequests); + } + } + + for (const auto &r : pendingRequests) { + std::string myPayload = r.acceptGzip ? payloadGzip : payload; + sendHttpResponseAndUnlock(MAX_U64, r, myPayload); + } + + if (preGenerate) unlockThread(lockedThreadId); + else sendHttpResponseAndUnlock(lockedThreadId, req, req.acceptGzip ? payloadGzip : payload); +} + + +void WebServer::runReader(ThreadPool::Thread &thr) { + Decompressor decomp; + + while(1) { + auto newMsgs = thr.inbox.pop_all(); + + auto txn = env.txn_ro(); + + for (auto &newMsg : newMsgs) { + if (auto msg = std::get_if(&newMsg.msg)) { + try { + handleReadRequest(txn, decomp, msg->lockedThreadId, msg->req); + } catch (std::exception &e) { + HTTPResponse res; + res.code = "500 Server Error"; + res.body = "Server error"; + + std::string payload = res.encode(false); + + sendHttpResponseAndUnlock(msg->lockedThreadId, msg->req, payload); + LE << "500 server error: " << e.what(); + } + } + } + } +} diff --git a/src/apps/web/WebServer.h b/src/apps/web/WebServer.h new file mode 100644 index 0000000..65b575e --- /dev/null +++ b/src/apps/web/WebServer.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "golpe.h" + +#include "HTTP.h" +#include "ThreadPool.h" +#include "Decompressor.h" + + + +struct Connection : NonCopyable { + uWS::HttpSocket *httpsocket; + uint64_t connId; + uint64_t connectedTimestamp; + flat_hash_set pendingRequests; + + Connection(uWS::HttpSocket *hs, uint64_t connId_) + : httpsocket(hs), connId(connId_), connectedTimestamp(hoytech::curr_time_us()) { } + Connection(const Connection &) = delete; + Connection(Connection &&) = delete; +}; + + + + +struct MsgHttpsocket : NonCopyable { + struct Send { + uint64_t connId; + uWS::HttpResponse *res; + std::string payload; + uint64_t lockedThreadId; + }; + + struct Unlock { + uint64_t lockedThreadId; + }; + + using Var = std::variant; + Var msg; + MsgHttpsocket(Var &&msg_) : msg(std::move(msg_)) {} +}; + +struct MsgWebReader : NonCopyable { + struct Request { + HTTPRequest req; + uint64_t lockedThreadId; + }; + + using Var = std::variant; + Var msg; + MsgWebReader(Var &&msg_) : msg(std::move(msg_)) {} +}; + +struct MsgWebWriter : NonCopyable { + struct Request { + HTTPRequest req; + }; + + using Var = std::variant; + Var msg; + MsgWebWriter(Var &&msg_) : msg(std::move(msg_)) {} +}; + + +struct WebServer { + std::unique_ptr hubTrigger; + + + // HTTP response cache + + struct CacheItem { + std::mutex lock; + + uint64_t expiry; + uint64_t softExpiry; + + std::string payload; + std::string payloadGzip; + std::string eTag; + + bool generationInProgress = false; + std::vector pendingRequests; + }; + + std::mutex cacheLock; + flat_hash_map> cache; + + + // Thread Pools + + ThreadPool tpHttpsocket; + ThreadPool tpReader; + ThreadPool tpWriter; + + void run(); + + void runHttpsocket(ThreadPool::Thread &thr); + void dispatchPostRequest(); + + void runReader(ThreadPool::Thread &thr); + void handleReadRequest(lmdb::txn &txn, Decompressor &decomp, uint64_t lockedThreadId, HTTPRequest &req); + HTTPResponse generateReadResponse(lmdb::txn &txn, Decompressor &decomp, const HTTPRequest &req, uint64_t &cacheTime); + + void runWriter(ThreadPool::Thread &thr); + + // Utils + + void unlockThread(uint64_t lockedThreadId) { + tpHttpsocket.dispatch(0, MsgHttpsocket{MsgHttpsocket::Unlock{lockedThreadId}}); + hubTrigger->send(); + } + + // Moves from payload! + void sendHttpResponseAndUnlock(uint64_t lockedThreadId, const HTTPRequest &req, std::string &payload) { + tpHttpsocket.dispatch(0, MsgHttpsocket{MsgHttpsocket::Send{req.connId, req.res, std::move(payload), lockedThreadId}}); + hubTrigger->send(); + } + + void sendHttpResponse(const HTTPRequest &req, std::string_view body, std::string_view code = "200 OK", std::string_view contentType = "text/html; charset=utf-8") { + HTTPResponse res; + res.code = code; + res.contentType = contentType; + res.body = std::string(body); // FIXME: copy + + std::string payload = res.encode(false); + + sendHttpResponseAndUnlock(MAX_U64, req, payload); + } +}; diff --git a/src/apps/web/WebUtils.h b/src/apps/web/WebUtils.h new file mode 100644 index 0000000..1eb4439 --- /dev/null +++ b/src/apps/web/WebUtils.h @@ -0,0 +1,56 @@ +#pragma once + +#include + + +struct Url { + std::vector path; + std::string_view query; + + Url(std::string_view u) { + size_t pos; + + if ((pos = u.find("?")) != std::string::npos) { + query = u.substr(pos + 1); + u = u.substr(0, pos); + } + + while ((pos = u.find("/")) != std::string::npos) { + if (pos != 0) path.emplace_back(u.substr(0, pos)); + u = u.substr(pos + 1); + } + + if (u.size()) path.emplace_back(u); + } +}; + +inline std::string renderTimestamp(uint64_t now, uint64_t ts) { + uint64_t delta = now > ts ? now - ts : ts - now; + + const uint64_t A = 60; + const uint64_t B = A*60; + const uint64_t C = B*24; + const uint64_t D = C*30.5; + const uint64_t E = D*12; + + std::string output; + + if (delta < B) output += std::to_string(delta / A) + " minutes"; + else if (delta < C) output += std::to_string(delta / B) + " hours"; + else if (delta < D) output += std::to_string(delta / C) + " days"; + else if (delta < E) output += std::to_string(delta / D) + " months"; + else output += std::to_string(delta / E) + " years"; + + if (now > ts) output += " ago"; + else output += " in the future"; + + return output; +} + +inline std::string renderPoints(double points) { + char buf[100]; + + snprintf(buf, sizeof(buf), "%g", points); + + return std::string(buf); +} diff --git a/src/apps/web/WebWriter.cpp b/src/apps/web/WebWriter.cpp new file mode 100644 index 0000000..b310a9c --- /dev/null +++ b/src/apps/web/WebWriter.cpp @@ -0,0 +1,92 @@ +#include "WebServer.h" +#include "WebUtils.h" +#include "Bech32Utils.h" + +#include "PluginEventSifter.h" +#include "events.h" + + +void WebServer::runWriter(ThreadPool::Thread &thr) { + secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); + PluginEventSifter writePolicy; + + while(1) { + auto newMsgs = thr.inbox.pop_all(); + auto now = hoytech::curr_time_us(); + + std::vector newEvents; + + for (auto &newMsg : newMsgs) { + if (auto msg = std::get_if(&newMsg.msg)) { + auto &req = msg->req; + EventSourceType sourceType = req.ipAddr.size() == 4 ? EventSourceType::IP4 : EventSourceType::IP6; + + Url u(req.url); + if (u.path.size() != 1 || u.path[0] != "submit-post") { + sendHttpResponse(req, "Not found", "404 Not Found"); + continue; + } + + std::string flatStr, jsonStr; + + try { + tao::json::value json = tao::json::from_string(req.body); + parseAndVerifyEvent(json, secpCtx, true, true, flatStr, jsonStr); + } catch(std::exception &e) { + sendHttpResponse(req, tao::json::to_string(tao::json::value({{ "message", e.what() }})), "404 Not Found", "application/json; charset=utf-8"); + continue; + } + + newEvents.emplace_back(std::move(flatStr), std::move(jsonStr), now, sourceType, req.ipAddr, &req); + } + } + + try { + auto txn = env.txn_rw(); + writeEvents(txn, newEvents); + txn.commit(); + } catch (std::exception &e) { + LE << "Error writing " << newEvents.size() << " events: " << e.what(); + + for (auto &newEvent : newEvents) { + std::string message = "Write error: "; + message += e.what(); + + HTTPRequest &req = *static_cast(newEvent.userData); + sendHttpResponse(req, tao::json::to_string(tao::json::value({{ "message", message }})), "500 Server Error", "application/json; charset=utf-8"); + } + + continue; + } + + + for (auto &newEvent : newEvents) { + auto *flat = flatbuffers::GetRoot(newEvent.flatStr.data()); + auto eventIdHex = to_hex(sv(flat->id())); + + tao::json::value output = tao::json::empty_object; + std::string message; + + if (newEvent.status == EventWriteStatus::Written) { + LI << "Inserted event. id=" << eventIdHex << " levId=" << newEvent.levId; + output["message"] = message = "ok"; + output["written"] = true; + output["event"] = encodeBech32Simple("note", sv(flat->id())); + } else if (newEvent.status == EventWriteStatus::Duplicate) { + output["message"] = message = "duplicate: have this event"; + output["written"] = true; + } else if (newEvent.status == EventWriteStatus::Replaced) { + output["message"] = message = "replaced: have newer event"; + } else if (newEvent.status == EventWriteStatus::Deleted) { + output["message"] = message = "deleted: user requested deletion"; + } + + if (newEvent.status != EventWriteStatus::Written) { + LI << "Rejected event. " << message << ", id=" << eventIdHex; + } + + HTTPRequest &req = *static_cast(newEvent.userData); + sendHttpResponse(req, tao::json::to_string(output), "200 OK", "application/json; charset=utf-8"); + } + } +} diff --git a/src/apps/web/bech32.cpp b/src/apps/web/bech32.cpp new file mode 100644 index 0000000..50fed25 --- /dev/null +++ b/src/apps/web/bech32.cpp @@ -0,0 +1,229 @@ +/* Copyright (c) 2017, 2021 Pieter Wuille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +// Copyright (c) 2017 Pieter Wuille +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include "bech32.h" + +#include +#include + +#include +#include + +namespace bech32 +{ + +namespace +{ + +typedef std::vector data; + +/** The Bech32 character set for encoding. */ +const char* CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +/** The Bech32 character set for decoding. */ +const int8_t CHARSET_REV[128] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 +}; + +/** Concatenate two byte arrays. */ +data cat(data x, const data& y) { + x.insert(x.end(), y.begin(), y.end()); + return x; +} + +/* Determine the final constant to use for the specified encoding. */ +uint32_t encoding_constant(Encoding encoding) { + assert(encoding == Encoding::BECH32 || encoding == Encoding::BECH32M); + return encoding == Encoding::BECH32 ? 1 : 0x2bc830a3; +} + +/** This function will compute what 6 5-bit values to XOR into the last 6 input values, in order to + * make the checksum 0. These 6 values are packed together in a single 30-bit integer. The higher + * bits correspond to earlier values. */ +uint32_t polymod(const data& values) +{ + // The input is interpreted as a list of coefficients of a polynomial over F = GF(32), with an + // implicit 1 in front. If the input is [v0,v1,v2,v3,v4], that polynomial is v(x) = + // 1*x^5 + v0*x^4 + v1*x^3 + v2*x^2 + v3*x + v4. The implicit 1 guarantees that + // [v0,v1,v2,...] has a distinct checksum from [0,v0,v1,v2,...]. + + // The output is a 30-bit integer whose 5-bit groups are the coefficients of the remainder of + // v(x) mod g(x), where g(x) is the Bech32 generator, + // x^6 + {29}x^5 + {22}x^4 + {20}x^3 + {21}x^2 + {29}x + {18}. g(x) is chosen in such a way + // that the resulting code is a BCH code, guaranteeing detection of up to 3 errors within a + // window of 1023 characters. Among the various possible BCH codes, one was selected to in + // fact guarantee detection of up to 4 errors within a window of 89 characters. + + // Note that the coefficients are elements of GF(32), here represented as decimal numbers + // between {}. In this finite field, addition is just XOR of the corresponding numbers. For + // example, {27} + {13} = {27 ^ 13} = {22}. Multiplication is more complicated, and requires + // treating the bits of values themselves as coefficients of a polynomial over a smaller field, + // GF(2), and multiplying those polynomials mod a^5 + a^3 + 1. For example, {5} * {26} = + // (a^2 + 1) * (a^4 + a^3 + a) = (a^4 + a^3 + a) * a^2 + (a^4 + a^3 + a) = a^6 + a^5 + a^4 + a + // = a^3 + 1 (mod a^5 + a^3 + 1) = {9}. + + // During the course of the loop below, `c` contains the bitpacked coefficients of the + // polynomial constructed from just the values of v that were processed so far, mod g(x). In + // the above example, `c` initially corresponds to 1 mod g(x), and after processing 2 inputs of + // v, it corresponds to x^2 + v0*x + v1 mod g(x). As 1 mod g(x) = 1, that is the starting value + // for `c`. + uint32_t c = 1; + for (const auto v_i : values) { + // We want to update `c` to correspond to a polynomial with one extra term. If the initial + // value of `c` consists of the coefficients of c(x) = f(x) mod g(x), we modify it to + // correspond to c'(x) = (f(x) * x + v_i) mod g(x), where v_i is the next input to + // process. Simplifying: + // c'(x) = (f(x) * x + v_i) mod g(x) + // ((f(x) mod g(x)) * x + v_i) mod g(x) + // (c(x) * x + v_i) mod g(x) + // If c(x) = c0*x^5 + c1*x^4 + c2*x^3 + c3*x^2 + c4*x + c5, we want to compute + // c'(x) = (c0*x^5 + c1*x^4 + c2*x^3 + c3*x^2 + c4*x + c5) * x + v_i mod g(x) + // = c0*x^6 + c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + v_i mod g(x) + // = c0*(x^6 mod g(x)) + c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + v_i + // If we call (x^6 mod g(x)) = k(x), this can be written as + // c'(x) = (c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + v_i) + c0*k(x) + + // First, determine the value of c0: + uint8_t c0 = c >> 25; + + // Then compute c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + v_i: + c = ((c & 0x1ffffff) << 5) ^ v_i; + + // Finally, for each set bit n in c0, conditionally add {2^n}k(x): + if (c0 & 1) c ^= 0x3b6a57b2; // k(x) = {29}x^5 + {22}x^4 + {20}x^3 + {21}x^2 + {29}x + {18} + if (c0 & 2) c ^= 0x26508e6d; // {2}k(x) = {19}x^5 + {5}x^4 + x^3 + {3}x^2 + {19}x + {13} + if (c0 & 4) c ^= 0x1ea119fa; // {4}k(x) = {15}x^5 + {10}x^4 + {2}x^3 + {6}x^2 + {15}x + {26} + if (c0 & 8) c ^= 0x3d4233dd; // {8}k(x) = {30}x^5 + {20}x^4 + {4}x^3 + {12}x^2 + {30}x + {29} + if (c0 & 16) c ^= 0x2a1462b3; // {16}k(x) = {21}x^5 + x^4 + {8}x^3 + {24}x^2 + {21}x + {19} + } + return c; +} + +/** Convert to lower case. */ +unsigned char lc(unsigned char c) { + return (c >= 'A' && c <= 'Z') ? (c - 'A') + 'a' : c; +} + +/** Expand a HRP for use in checksum computation. */ +data expand_hrp(const std::string& hrp) { + data ret; + ret.reserve(hrp.size() + 90); + ret.resize(hrp.size() * 2 + 1); + for (size_t i = 0; i < hrp.size(); ++i) { + unsigned char c = hrp[i]; + ret[i] = c >> 5; + ret[i + hrp.size() + 1] = c & 0x1f; + } + ret[hrp.size()] = 0; + return ret; +} + +/** Verify a checksum. */ +Encoding verify_checksum(const std::string& hrp, const data& values) { + // PolyMod computes what value to xor into the final values to make the checksum 0. However, + // if we required that the checksum was 0, it would be the case that appending a 0 to a valid + // list of values would result in a new valid list. For that reason, Bech32 requires the + // resulting checksum to be 1 instead. In Bech32m, this constant was amended. + uint32_t check = polymod(cat(expand_hrp(hrp), values)); + if (check == encoding_constant(Encoding::BECH32)) return Encoding::BECH32; + if (check == encoding_constant(Encoding::BECH32M)) return Encoding::BECH32M; + return Encoding::INVALID; +} + +data create_checksum(const std::string& hrp, const data& values, Encoding encoding) { + data enc = cat(expand_hrp(hrp), values); + enc.resize(enc.size() + 6); + uint32_t mod = polymod(enc) ^ encoding_constant(encoding); + data ret; + ret.resize(6); + for (size_t i = 0; i < 6; ++i) { + // Convert the 5-bit groups in mod to checksum values. + ret[i] = (mod >> (5 * (5 - i))) & 31; + } + return ret; +} + +} // namespace + + +typedef std::vector data; + +/** Encode a Bech32 or Bech32m string. */ +std::string encode(const std::string& hrp, const data& values, Encoding encoding) { + // First ensure that the HRP is all lowercase. BIP-173 requires an encoder + // to return a lowercase Bech32 string, but if given an uppercase HRP, the + // result will always be invalid. + for (const char& c : hrp) assert(c < 'A' || c > 'Z'); + data checksum = create_checksum(hrp, values, encoding); + data combined = cat(values, checksum); + std::string ret = hrp + '1'; + ret.reserve(ret.size() + combined.size()); + for (const auto c : combined) { + ret += CHARSET[c]; + } + return ret; +} + +/** Decode a Bech32 or Bech32m string. */ +DecodeResult decode(const std::string& str) { + bool lower = false, upper = false; + for (size_t i = 0; i < str.size(); ++i) { + unsigned char c = str[i]; + if (c >= 'a' && c <= 'z') lower = true; + else if (c >= 'A' && c <= 'Z') upper = true; + else if (c < 33 || c > 126) return {}; + } + if (lower && upper) return {}; + size_t pos = str.rfind('1'); + if (str.size() > 90 || pos == str.npos || pos == 0 || pos + 7 > str.size()) { + return {}; + } + data values(str.size() - 1 - pos); + for (size_t i = 0; i < str.size() - 1 - pos; ++i) { + unsigned char c = str[i + pos + 1]; + int8_t rev = CHARSET_REV[c]; + + if (rev == -1) { + return {}; + } + values[i] = rev; + } + std::string hrp; + for (size_t i = 0; i < pos; ++i) { + hrp += lc(str[i]); + } + Encoding result = verify_checksum(hrp, values); + if (result == Encoding::INVALID) return {}; + return {result, std::move(hrp), data(values.begin(), values.end() - 6)}; +} + +} // namespace bech32 diff --git a/src/apps/web/bech32.h b/src/apps/web/bech32.h new file mode 100644 index 0000000..8d18434 --- /dev/null +++ b/src/apps/web/bech32.h @@ -0,0 +1,61 @@ +/* Copyright (c) 2017, 2021 Pieter Wuille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +#ifndef BECH32_H_ +#define BECH32_H_ 1 + +#include +#include +#include + +#include + +namespace bech32 +{ + +enum class Encoding { + INVALID, + + BECH32, //! Bech32 encoding as defined in BIP173 + BECH32M, //! Bech32m encoding as defined in BIP350 +}; + +/** Encode a Bech32 or Bech32m string. If hrp contains uppercase characters, this will cause an + * assertion error. Encoding must be one of BECH32 or BECH32M. */ +std::string encode(const std::string& hrp, const std::vector& values, Encoding encoding); + +/** A type for the result of decoding. */ +struct DecodeResult +{ + Encoding encoding; //!< What encoding was detected in the result; Encoding::INVALID if failed. + std::string hrp; //!< The human readable part + std::vector data; //!< The payload (excluding checksum) + + DecodeResult() : encoding(Encoding::INVALID) {} + DecodeResult(Encoding enc, std::string&& h, std::vector&& d) : encoding(enc), hrp(std::move(h)), data(std::move(d)) {} +}; + +/** Decode a Bech32 or Bech32m string. */ +DecodeResult decode(const std::string& str); + +} // namespace bech32 + +#endif // BECH32_H_ diff --git a/src/apps/web/cmd_algo.cpp b/src/apps/web/cmd_algo.cpp new file mode 100644 index 0000000..0221531 --- /dev/null +++ b/src/apps/web/cmd_algo.cpp @@ -0,0 +1,56 @@ +#include + +#include "golpe.h" + +#include "WebData.h" +#include "AlgoScanner.h" +#include "Decompressor.h" + + +static const char USAGE[] = +R"( + Usage: + algo scan +)"; + + +void cmd_algo(const std::vector &subArgs) { + std::map args = docopt::docopt(USAGE, subArgs, true, ""); + + std::string descriptor = args[""].asString(); + + + UserCache userCache; + Decompressor decomp; + auto txn = env.txn_ro(); + + auto communitySpec = lookupCommunitySpec(txn, decomp, userCache, descriptor); + + AlgoScanner a(txn, communitySpec.algo); + auto events = a.getEvents(txn, decomp, 300); + + for (const auto &e : events) { + auto ev = Event::fromLevId(txn, e.levId); + ev.populateJson(txn, decomp); + std::cout << e.info.score << "/" << e.info.comments << " : " << ev.summaryHtml() << "\n"; + } + + +/* + std::string str; + + { + std::string line; + while (std::getline(std::cin, line)) { + str += line; + str += "\n"; + } + } + + auto alg = parseAlgo(txn, str); + + for (const auto &[k, v] : alg.variableIndexLookup) { + LI << k << " = " << alg.pubkeySets[v].size() << " recs"; + } + */ +} diff --git a/src/apps/web/cmd_web.cpp b/src/apps/web/cmd_web.cpp new file mode 100644 index 0000000..3aab146 --- /dev/null +++ b/src/apps/web/cmd_web.cpp @@ -0,0 +1,39 @@ +#include "WebServer.h" + + + +void cmd_web(const std::vector &subArgs) { + WebServer s; + s.run(); +} + +void WebServer::run() { + tpHttpsocket.init("Httpsocket", 1, [this](auto &thr){ + runHttpsocket(thr); + }); + + + // FIXME: cfg().web__numThreads__* + + tpReader.init("Reader", 3, [this](auto &thr){ + runReader(thr); + }); + + tpWriter.init("Writer", 1, [this](auto &thr){ + runWriter(thr); + }); + + + // Monitor for config file reloads + + auto configFileChangeWatcher = hoytech::file_change_monitor(configFile); + + configFileChangeWatcher.setDebounce(100); + + configFileChangeWatcher.run([&](){ + loadConfig(configFile); + }); + + + tpHttpsocket.join(); +} diff --git a/src/apps/web/golpe.yaml b/src/apps/web/golpe.yaml new file mode 100644 index 0000000..7ccac2b --- /dev/null +++ b/src/apps/web/golpe.yaml @@ -0,0 +1,12 @@ +config: + - name: web__bind + desc: "Interface to listen on. Use 0.0.0.0 to listen on all interfaces" + default: "127.0.0.1" + noReload: true + - name: web__port + desc: "Port to open for the http protocol" + default: 8080 + noReload: true + - name: web__homepageCommunity + desc: "Community descriptor for homepage" + default: "" diff --git a/src/apps/web/homepage-community.yaml b/src/apps/web/homepage-community.yaml new file mode 100644 index 0000000..39ca116 --- /dev/null +++ b/src/apps/web/homepage-community.yaml @@ -0,0 +1,19 @@ +name: homepage + +desc: | + Oddbean.com's homepage + +algo: | + let doug = npub1yxprsscnjw2e6myxz73mmzvnqw5kvzd5ffjya9ecjypc5l0gvgksh8qud4; + let admins = doug.following; + let members = admins.following; + + posts { + threshold = 80; + + mods = admins; + voters = members; + + /10 if ~ /(?i)bitcoin|btc|crypto/; + *3.5 if ~ /(?i)#grownostr/; + } diff --git a/src/apps/web/install-homepage-community.sh b/src/apps/web/install-homepage-community.sh new file mode 100644 index 0000000..a001ee4 --- /dev/null +++ b/src/apps/web/install-homepage-community.sh @@ -0,0 +1 @@ +nostril --envelope --sec c1eee22f68dc218d98263cfecb350db6fc6b3e836b47423b66c62af7ae3e32bb --content "$(perl -MYAML -MJSON::XS -E 'print encode_json(YAML::LoadFile(q{ALGO}));')" --kind 33700 --tag d homepage | websocat ws://127.0.0.1:7777 diff --git a/src/apps/web/rules.mk b/src/apps/web/rules.mk new file mode 100644 index 0000000..62a72d0 --- /dev/null +++ b/src/apps/web/rules.mk @@ -0,0 +1,6 @@ +build/WebTemplates.h: $(shell find src/apps/web/tmpls/ -type f -name '*.tmpl') + perl golpe/external/templar/templar.pl src/apps/web/tmpls/ tmpl $@ + +src/apps/web/WebReader.o: build/WebTemplates.h + +LDLIBS += -lre2 diff --git a/src/apps/web/static/.gitignore b/src/apps/web/static/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/src/apps/web/static/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/src/apps/web/static/Makefile b/src/apps/web/static/Makefile new file mode 100644 index 0000000..4fe8fc5 --- /dev/null +++ b/src/apps/web/static/Makefile @@ -0,0 +1,19 @@ +CSS := reset.css oddbean.css +JS := base.ts turbo.js oddbean.js alpine.js + +.PHONY: all css js logo + +all: css js logo + +css: $(CSS) + mkdir -p build/ + cat $(CSS) | sassc -s -t compressed > build/oddbean.css + gzip -k9f build/oddbean.css + +js: $(JS) + mkdir -p build/ + cat $(JS) | esbuild --loader=ts --minify > build/oddbean.js + gzip -k9f build/oddbean.js + +logo: + cp oddbean.svg build/ diff --git a/src/apps/web/static/alpine.js b/src/apps/web/static/alpine.js new file mode 100644 index 0000000..154023b --- /dev/null +++ b/src/apps/web/static/alpine.js @@ -0,0 +1,5 @@ +(()=>{var Ye=!1,Xe=!1,V=[],Ze=-1;function Bt(e){bn(e)}function bn(e){V.includes(e)||V.push(e),vn()}function ye(e){let t=V.indexOf(e);t!==-1&&t>Ze&&V.splice(t,1)}function vn(){!Xe&&!Ye&&(Ye=!0,queueMicrotask(wn))}function wn(){Ye=!1,Xe=!0;for(let e=0;ee.effect(t,{scheduler:r=>{et?Bt(r):r()}}),Qe=e.raw}function tt(e){P=e}function Vt(e){let t=()=>{};return[n=>{let i=P(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}var Ht=[],qt=[],Ut=[];function Wt(e){Ut.push(e)}function be(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,qt.push(t))}function Gt(e){Ht.push(e)}function Jt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function rt(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var it=new MutationObserver(nt),ot=!1;function se(){it.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ot=!0}function st(){En(),it.disconnect(),ot=!1}var ae=[],at=!1;function En(){ae=ae.concat(it.takeRecords()),ae.length&&!at&&(at=!0,queueMicrotask(()=>{Sn(),at=!1}))}function Sn(){nt(ae),ae.length=0}function h(e){if(!ot)return e();st();let t=e();return se(),t}var ct=!1,ve=[];function Yt(){ct=!0}function Xt(){ct=!1,nt(ve),ve=[]}function nt(e){if(ct){ve=ve.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{rt(s,o)}),n.forEach((o,s)=>{Ht.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(qt.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,Ut.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function we(e){return F(L(e))}function M(e,t,r){return e._x_dataStack=[t,...L(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function L(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?L(e.host):e.parentNode?L(e.parentNode):[]}function F(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function Ee(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Se(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>An(n,i),s=>lt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function An(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function lt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),lt(e[t[0]],t.slice(1),r)}}var Zt={};function y(e,t){Zt[e]=t}function ce(e,t){return Object.entries(Zt).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=ut(t);return i={interceptor:Se,...s},be(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function Qt(e,t,r,...n){try{return r(...n)}catch(i){X(i,e,t)}}function X(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Ae=!0;function er(e){let t=Ae;Ae=!1,e(),Ae=t}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return tr(...e)}var tr=ft;function rr(e){tr=e}function ft(e,t){let r={};ce(r,e);let n=[r,...L(e)],i=typeof t=="function"?On(n,t):Tn(n,t,e);return Qt.bind(null,e,t,i)}function On(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Oe(r,o)}}var dt={};function Cn(e,t){if(dt[e])return dt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(async()=>{ ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return X(s,t,e),Promise.resolve()}})();return dt[e]=o,o}function Tn(e,t,r){let n=Cn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>X(l,r,t));n.finished?(Oe(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Oe(i,l,a,s,r)}).catch(l=>X(l,r,t)).finally(()=>n.result=void 0)}}}function Oe(e,t,r,n,i){if(Ae&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Oe(e,s,r,n)).catch(s=>X(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var pt="x-";function O(e=""){return pt+e}function nr(e){pt=e}var mt={};function p(e,t){return mt[e]=t,{before(r){if(!mt[r]){console.warn("Cannot find directive `${directive}`. `${name}` will use the default order of execution");return}let n=H.indexOf(r);H.splice(n>=0?n:H.indexOf("DEFAULT"),0,e)}}}function le(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=ht(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(ir((o,s)=>n[o]=s)).filter(or).map(Mn(n,r)).sort(Nn).map(o=>Rn(e,o))}function ht(e){return Array.from(e).map(ir()).filter(t=>!or(t))}var _t=!1,ue=new Map,sr=Symbol();function ar(e){_t=!0;let t=Symbol();sr=t,ue.set(t,[]);let r=()=>{for(;ue.get(t).length;)ue.get(t).shift()();ue.delete(t)},n=()=>{_t=!1,r()};e(r),n()}function ut(e){let t=[],r=a=>t.push(a),[n,i]=Vt(e);return t.push(i),[{Alpine:j,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function Rn(e,t){let r=()=>{},n=mt[t.type]||r,[i,o]=ut(e);Jt(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),_t?ue.get(sr).push(n):n())};return s.runCleanups=o,s}var Te=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ce=e=>e;function ir(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=cr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var cr=[];function Z(e){cr.push(e)}function or({name:e}){return lr().test(e)}var lr=()=>new RegExp(`^${pt}([^:^.]+)\\b`);function Mn(e,t){return({name:r,value:n})=>{let i=r.match(lr()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var gt="DEFAULT",H=["ignore","ref","data","id","bind","init","for","model","modelable","transition","show","if",gt,"teleport"];function Nn(e,t){let r=H.indexOf(e.type)===-1?gt:e.type,n=H.indexOf(t.type)===-1?gt:t.type;return H.indexOf(r)-H.indexOf(n)}function q(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function S(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ur=!1;function dr(){ur&&S("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ur=!0,document.body||S("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + $(ctx.title)Oddbean + + + +
+ + + + + Oddbean + <> ?(ctx.communitySpec) + / $(ctx.communitySpec->name) + + + + (algo) ?(ctx.communitySpec) + + new post + + + +
+ +
+ $(ctx.body) +
+ + + + diff --git a/src/apps/web/tmpls/newPost.tmpl b/src/apps/web/tmpls/newPost.tmpl new file mode 100644 index 0000000..9bb74a6 --- /dev/null +++ b/src/apps/web/tmpls/newPost.tmpl @@ -0,0 +1,10 @@ +
+ + +
+ +
+ +
+
+
diff --git a/src/apps/web/tmpls/search/eventResult.tmpl b/src/apps/web/tmpls/search/eventResult.tmpl new file mode 100644 index 0000000..fccd449 --- /dev/null +++ b/src/apps/web/tmpls/search/eventResult.tmpl @@ -0,0 +1,6 @@ +$;( + auto &e = ctx; + auto noteId = e.getNoteId(); +) + +Note: $(noteId) diff --git a/src/apps/web/tmpls/search/userResult.tmpl b/src/apps/web/tmpls/search/userResult.tmpl new file mode 100644 index 0000000..6f4cd0f --- /dev/null +++ b/src/apps/web/tmpls/search/userResult.tmpl @@ -0,0 +1,3 @@ +$;( auto &u = ctx; ) + +User: $(u.username) diff --git a/src/apps/web/tmpls/searchPage.tmpl b/src/apps/web/tmpls/searchPage.tmpl new file mode 100644 index 0000000..ac81cca --- /dev/null +++ b/src/apps/web/tmpls/searchPage.tmpl @@ -0,0 +1,3 @@ +
@(const auto &r : ctx.results) + $(r) +
diff --git a/src/apps/web/tmpls/user/comments.tmpl b/src/apps/web/tmpls/user/comments.tmpl new file mode 100644 index 0000000..f14d022 --- /dev/null +++ b/src/apps/web/tmpls/user/comments.tmpl @@ -0,0 +1,11 @@ +
+

+ Notes by $(ctx.u.username) + | export + | rss +

+ +
@(auto &r : ctx.renderedThreads) + $(r) +
+
diff --git a/src/apps/web/tmpls/user/followers.tmpl b/src/apps/web/tmpls/user/followers.tmpl new file mode 100644 index 0000000..b9b3974 --- /dev/null +++ b/src/apps/web/tmpls/user/followers.tmpl @@ -0,0 +1,19 @@ +
+ + + + + + <> @(const auto &pubkey : ctx.followers) + $;( + auto *u = ctx.getUser(pubkey); + ) + + + + + +
user
+ $(u->username) +
+
diff --git a/src/apps/web/tmpls/user/following.tmpl b/src/apps/web/tmpls/user/following.tmpl new file mode 100644 index 0000000..a5c1967 --- /dev/null +++ b/src/apps/web/tmpls/user/following.tmpl @@ -0,0 +1,47 @@ +
+
?(!ctx.user.kind3Event) + No kind 3 contact list found for $(ctx.user.npubId) +
+ + ?(ctx.user.kind3Event) + + + + + + + <> @(const auto &tagJson : ctx.user.kind3Event->at("tags").get_array()) + $;( + const auto &tag = tagJson.get_array(); + if (tag.size() < 2) continue; + + std::string username, npubId, relay, petname; + + try { + auto &pubkey = tag.at(1).get_string(); + auto *u = ctx.getUser(from_hex(pubkey)); + npubId = u->npubId; + username = u->username.size() ? u->username : pubkey.substr(0, 8) + "..."; + } catch(...) {} + + if (npubId.empty()) continue; + + if (tag.at(0).get_string() != "p") continue; + if (tag.size() >= 3) relay = tag.at(2).get_string(); + if (tag.size() >= 4) petname = tag.at(3).get_string(); + ) + + + + + + + +
usermain relaypetname
+ $(username) + + $(relay) + + $(petname) +
+
diff --git a/src/apps/web/tmpls/user/metadata.tmpl b/src/apps/web/tmpls/user/metadata.tmpl new file mode 100644 index 0000000..7ccf047 --- /dev/null +++ b/src/apps/web/tmpls/user/metadata.tmpl @@ -0,0 +1,33 @@ + diff --git a/strfry.conf b/strfry.conf index b59e1ec..019fe18 100644 --- a/strfry.conf +++ b/strfry.conf @@ -139,3 +139,7 @@ relay { maxSyncEvents = 1000000 } } + +web { + homepageCommunity = "npub1qqa6nvk9hk90am2p5n8rv25t0lp6kkwztd4pxkw2ayyn72td4sqsy8jzyj/homepage" +}