This commit is contained in:
Doug Hoyte
2023-06-10 01:54:23 -04:00
parent 2da619ed23
commit 9b18a6e4c1
46 changed files with 8174 additions and 1 deletions

View File

@ -1,5 +1,5 @@
BIN ?= strfry
APPS ?= dbutils relay mesh
APPS ?= dbutils relay mesh web
OPT ?= -O3 -g
include golpe/rules.mk

435
src/apps/web/AlgoParser.h Normal file
View File

@ -0,0 +1,435 @@
#include <iostream>
#include <string>
#include <tao/pegtl.hpp>
#include <re2/re2.h>
#include "events.h"
#include "Bech32Utils.h"
struct AlgoCompiled {
double threshold = 20;
using PubkeySet = flat_hash_set<std::string>;
std::vector<PubkeySet> pubkeySets;
flat_hash_map<std::string, uint64_t> variableIndexLookup; // variableName -> index into pubkeySets
PubkeySet *mods = nullptr;
PubkeySet *voters = nullptr;
struct Filter {
std::unique_ptr<RE2> re;
char op;
double arg;
};
std::vector<Filter> 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<ExpressionState> expressionStateStack;
std::string currPubkeyDesc;
std::vector<std::string> 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<RE2>(val), currFilterOp, currFilterArg);
}
void loadFollowing(std::string_view pubkey, flat_hash_set<std::string> &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<uint64_t>(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);
}

110
src/apps/web/AlgoScanner.h Normal file
View File

@ -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<FilteredEvent> getEvents(lmdb::txn &txn, Decompressor &decomp, uint64_t limit) {
flat_hash_map<std::string, EventInfo> eventInfoCache;
std::vector<FilteredEvent> output;
env.generic_foreachFull(txn, env.dbi_Event__created_at, lmdb::to_sv<uint64_t>(MAX_U64), lmdb::to_sv<uint64_t>(MAX_U64), [&](auto k, auto v) {
if (output.size() > limit) return false;
auto ev = lookupEventByLevId(txn, lmdb::from_sv<uint64_t>(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<std::string> &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<uint64_t>(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;
});
}
};

View File

@ -0,0 +1,55 @@
#pragma once
#include <vector>
#include "bech32.h"
/** Convert from one power-of-2 number base to another. */
template<int frombits, int tobits, bool pad>
bool convertbits(std::vector<uint8_t>& out, const std::vector<uint8_t>& 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<uint8_t> values(32, '\0');
memcpy(values.data(), v.data(), 32);
std::vector<uint8_t> 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<uint8_t> 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());
}

89
src/apps/web/HTTP.h Normal file
View File

@ -0,0 +1,89 @@
#pragma once
#include <zlib.h>
#include <openssl/sha.h>
#include <mutex>
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<unsigned char*>(body.data()), body.size(), hash);
return to_hex(std::string_view(reinterpret_cast<char*>(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;
}
};

11
src/apps/web/README Normal file
View File

@ -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

27
src/apps/web/TODO Normal file
View File

@ -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/)

676
src/apps/web/WebData.h Normal file
View File

@ -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<tao::json::value> kind0Json;
std::optional<tao::json::value> 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<tao::json::value> loadKindJson(lmdb::txn &txn, Decompressor &decomp, uint64_t kind) {
std::optional<tao::json::value> 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<uint64_t>(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<tao::json::value> loadKindEvent(lmdb::txn &txn, Decompressor &decomp, uint64_t kind) {
std::optional<tao::json::value> 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<uint64_t>(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<std::string> getFollowers(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) {
std::vector<std::string> output;
flat_hash_set<std::string> 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<uint64_t>(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<std::string, User> 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("<a href=\"") + templarInternal::htmlEscape(firstUrl, true) + "\">" + content + "</a>";
}
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 += "<a href=\"";
output += url;
output += "\">";
output += text;
output += "</a>";
};
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<std::string, Event> eventCache;
flat_hash_map<std::string, flat_hash_set<std::string>> children; // parentEventId -> childEventIds
std::string pubkeyHighlight;
bool isFullThreadLoaded = false;
// Load all events under an eventId
EventThread(std::string rootEventId, bool isRootEventThreadRoot, flat_hash_map<std::string, Event> &&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<std::string> 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<uint64_t>(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<std::string> focusOnPubkey = std::nullopt) {
auto now = hoytech::curr_time_s();
flat_hash_set<uint64_t> 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<Reply> replies;
};
std::function<TemplarResult(const std::string &)> 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<Reply> 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<std::string, Event> eventCache; // eventId (non-root) -> Event
bool isRootEventFromUser = false;
bool isRootPresent = false;
uint64_t rootEventTimestamp = 0;
EventCluster(std::string rootEventId) : rootEventId(rootEventId) {}
};
std::vector<EventCluster> eventClusterArr;
UserEvents(lmdb::txn &txn, Decompressor &decomp, const std::string &pubkey) : u(txn, decomp, pubkey) {
flat_hash_map<std::string, EventCluster> 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<uint64_t>(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<TemplarResult> 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<TemplarResult> &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;
}

View File

@ -0,0 +1,148 @@
#include <queue>
#include "WebServer.h"
#include "app_git_version.h"
void WebServer::runHttpsocket(ThreadPool<MsgHttpsocket>::Thread &thr) {
uWS::Hub hub;
uWS::Group<uWS::SERVER> *hubGroup;
flat_hash_map<uint64_t, Connection*> connIdToConnection;
uint64_t nextConnectionId = 1;
flat_hash_map<uWS::HttpResponse *, HTTPRequest> receivingRequests;
std::vector<bool> tpReaderLock(tpReader.numThreads, false);
std::queue<MsgWebReader> pendingReaderMessages;
{
int extensionOptions = 0;
hubGroup = hub.createGroup<uWS::SERVER>(extensionOptions);
}
hubGroup->onHttpConnection([&](uWS::HttpSocket<uWS::SERVER> *hs) {
uint64_t connId = nextConnectionId++;
Connection *c = new Connection(hs, connId);
hs->setUserData((void*)c);
connIdToConnection.emplace(connId, c);
});
hubGroup->onHttpDisconnection([&](uWS::HttpSocket<uWS::SERVER> *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<MsgWebReader::Request>(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<MsgWebReader::Request>(pendingReaderMessages.front().msg).lockedThreadId = lockedThreadId;
tpReader.dispatch(lockedThreadId, std::move(pendingReaderMessages.front()));
pendingReaderMessages.pop();
}
};
std::function<void()> asyncCb = [&]{
auto newMsgs = thr.inbox.pop_all_no_wait();
for (auto &newMsg : newMsgs) {
if (auto msg = std::get_if<MsgHttpsocket::Send>(&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<MsgHttpsocket::Unlock>(&newMsg.msg)) {
unlockThread(msg->lockedThreadId);
}
}
};
hubTrigger = std::make_unique<uS::Async>(hub.getLoop());
hubTrigger->setData(&asyncCb);
hubTrigger->start([](uS::Async *a){
auto *r = static_cast<std::function<void()> *>(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();
}

432
src/apps/web/WebReader.cpp Normal file
View File

@ -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<uint64_t>(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<uint64_t>(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<TemplarResult> &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<TemplarResult> 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<TemplarResult> body;
std::optional<CommunitySpec> communitySpec;
std::string title;
// Or, raw:
std::optional<std::string> 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<const User*(const std::string &)> 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<std::string> &followers;
std::function<const User*(const std::string &)> 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<TemplarResult> 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<TemplarResult> &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> &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<std::mutex> 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<std::mutex> 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<HTTPRequest> pendingRequests;
{
CacheItem *item = nullptr;
{
std::lock_guard<std::mutex> guard(cacheLock);
item = cache.emplace(req.url, std::make_unique<CacheItem>()).first->second.get();
}
{
std::lock_guard<std::mutex> 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<MsgWebReader>::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<MsgWebReader::Request>(&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();
}
}
}
}
}

140
src/apps/web/WebServer.h Normal file
View File

@ -0,0 +1,140 @@
#pragma once
#include <iostream>
#include <memory>
#include <algorithm>
#include <mutex>
#include <hoytech/time.h>
#include <hoytech/hex.h>
#include <hoytech/file_change_monitor.h>
#include <uWebSockets/src/uWS.h>
#include <tao/json.hpp>
#include "golpe.h"
#include "HTTP.h"
#include "ThreadPool.h"
#include "Decompressor.h"
struct Connection : NonCopyable {
uWS::HttpSocket<uWS::SERVER> *httpsocket;
uint64_t connId;
uint64_t connectedTimestamp;
flat_hash_set<uWS::HttpResponse *> pendingRequests;
Connection(uWS::HttpSocket<uWS::SERVER> *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<Send, Unlock>;
Var msg;
MsgHttpsocket(Var &&msg_) : msg(std::move(msg_)) {}
};
struct MsgWebReader : NonCopyable {
struct Request {
HTTPRequest req;
uint64_t lockedThreadId;
};
using Var = std::variant<Request>;
Var msg;
MsgWebReader(Var &&msg_) : msg(std::move(msg_)) {}
};
struct MsgWebWriter : NonCopyable {
struct Request {
HTTPRequest req;
};
using Var = std::variant<Request>;
Var msg;
MsgWebWriter(Var &&msg_) : msg(std::move(msg_)) {}
};
struct WebServer {
std::unique_ptr<uS::Async> 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<HTTPRequest> pendingRequests;
};
std::mutex cacheLock;
flat_hash_map<std::string, std::unique_ptr<CacheItem>> cache;
// Thread Pools
ThreadPool<MsgHttpsocket> tpHttpsocket;
ThreadPool<MsgWebReader> tpReader;
ThreadPool<MsgWebWriter> tpWriter;
void run();
void runHttpsocket(ThreadPool<MsgHttpsocket>::Thread &thr);
void dispatchPostRequest();
void runReader(ThreadPool<MsgWebReader>::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<MsgWebWriter>::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);
}
};

56
src/apps/web/WebUtils.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
#include <string>
struct Url {
std::vector<std::string_view> 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);
}

View File

@ -0,0 +1,92 @@
#include "WebServer.h"
#include "WebUtils.h"
#include "Bech32Utils.h"
#include "PluginEventSifter.h"
#include "events.h"
void WebServer::runWriter(ThreadPool<MsgWebWriter>::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<EventToWrite> newEvents;
for (auto &newMsg : newMsgs) {
if (auto msg = std::get_if<MsgWebWriter::Request>(&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<HTTPRequest*>(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<NostrIndex::Event>(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<HTTPRequest*>(newEvent.userData);
sendHttpResponse(req, tao::json::to_string(output), "200 OK", "application/json; charset=utf-8");
}
}
}

229
src/apps/web/bech32.cpp Normal file
View File

@ -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 <tuple>
#include <vector>
#include <assert.h>
#include <stdint.h>
namespace bech32
{
namespace
{
typedef std::vector<uint8_t> 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<uint8_t> 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

61
src/apps/web/bech32.h Normal file
View File

@ -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 <string>
#include <tuple>
#include <vector>
#include <stdint.h>
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<uint8_t>& 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<uint8_t> data; //!< The payload (excluding checksum)
DecodeResult() : encoding(Encoding::INVALID) {}
DecodeResult(Encoding enc, std::string&& h, std::vector<uint8_t>&& 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_

56
src/apps/web/cmd_algo.cpp Normal file
View File

@ -0,0 +1,56 @@
#include <docopt.h>
#include "golpe.h"
#include "WebData.h"
#include "AlgoScanner.h"
#include "Decompressor.h"
static const char USAGE[] =
R"(
Usage:
algo scan <descriptor>
)";
void cmd_algo(const std::vector<std::string> &subArgs) {
std::map<std::string, docopt::value> args = docopt::docopt(USAGE, subArgs, true, "");
std::string descriptor = args["<descriptor>"].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";
}
*/
}

39
src/apps/web/cmd_web.cpp Normal file
View File

@ -0,0 +1,39 @@
#include "WebServer.h"
void cmd_web(const std::vector<std::string> &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();
}

12
src/apps/web/golpe.yaml Normal file
View File

@ -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: ""

View File

@ -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/;
}

View File

@ -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

6
src/apps/web/rules.mk Normal file
View File

@ -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

1
src/apps/web/static/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build/

View File

@ -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/

File diff suppressed because one or more lines are too long

507
src/apps/web/static/base.ts Normal file
View File

@ -0,0 +1,507 @@
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */
// Utilities
function assertNumber(n: number) {
if (!Number.isSafeInteger(n)) throw new Error(`Wrong integer: ${n}`);
}
interface Coder<F, T> {
encode(from: F): T;
decode(to: T): F;
}
interface BytesCoder extends Coder<Uint8Array, string> {
encode: (data: Uint8Array) => string;
decode: (str: string) => Uint8Array;
}
// TODO: some recusive type inference so it would check correct order of input/output inside rest?
// like <string, number>, <number, bytes>, <bytes, float>
type Chain = [Coder<any, any>, ...Coder<any, any>[]];
// Extract info from Coder type
type Input<F> = F extends Coder<infer T, any> ? T : never;
type Output<F> = F extends Coder<any, infer T> ? T : never;
// Generic function for arrays
type First<T> = T extends [infer U, ...any[]] ? U : never;
type Last<T> = T extends [...any[], infer U] ? U : never;
type Tail<T> = T extends [any, ...infer U] ? U : never;
type AsChain<C extends Chain, Rest = Tail<C>> = {
// C[K] = Coder<Input<C[K]>, Input<Rest[k]>>
[K in keyof C]: Coder<Input<C[K]>, Input<K extends keyof Rest ? Rest[K] : any>>;
};
function chain<T extends Chain & AsChain<T>>(...args: T): Coder<Input<First<T>>, Output<Last<T>>> {
// Wrap call in closure so JIT can inline calls
const wrap = (a: any, b: any) => (c: any) => a(b(c));
// Construct chain of args[-1].encode(args[-2].encode([...]))
const encode = Array.from(args)
.reverse()
.reduce((acc, i: any) => (acc ? wrap(acc, i.encode) : i.encode), undefined) as any;
// Construct chain of args[0].decode(args[1].decode(...))
const decode = args.reduce(
(acc, i: any) => (acc ? wrap(acc, i.decode) : i.decode),
undefined
) as any;
return { encode, decode };
}
type Alphabet = string[] | string;
// Encodes integer radix representation to array of strings using alphabet and back
function alphabet(alphabet: Alphabet): Coder<number[], string[]> {
return {
encode: (digits: number[]) => {
if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== 'number'))
throw new Error('alphabet.encode input should be an array of numbers');
return digits.map((i) => {
assertNumber(i);
if (i < 0 || i >= alphabet.length)
throw new Error(`Digit index outside alphabet: ${i} (alphabet: ${alphabet.length})`);
return alphabet[i];
});
},
decode: (input: string[]) => {
if (!Array.isArray(input) || (input.length && typeof input[0] !== 'string'))
throw new Error('alphabet.decode input should be array of strings');
return input.map((letter) => {
if (typeof letter !== 'string')
throw new Error(`alphabet.decode: not string element=${letter}`);
const index = alphabet.indexOf(letter);
if (index === -1) throw new Error(`Unknown letter: "${letter}". Allowed: ${alphabet}`);
return index;
});
},
};
}
function join(separator = ''): Coder<string[], string> {
if (typeof separator !== 'string') throw new Error('join separator should be string');
return {
encode: (from) => {
if (!Array.isArray(from) || (from.length && typeof from[0] !== 'string'))
throw new Error('join.encode input should be array of strings');
for (let i of from)
if (typeof i !== 'string') throw new Error(`join.encode: non-string input=${i}`);
return from.join(separator);
},
decode: (to) => {
if (typeof to !== 'string') throw new Error('join.decode input should be string');
return to.split(separator);
},
};
}
// Pad strings array so it has integer number of bits
function padding(bits: number, chr = '='): Coder<string[], string[]> {
assertNumber(bits);
if (typeof chr !== 'string') throw new Error('padding chr should be string');
return {
encode(data: string[]): string[] {
if (!Array.isArray(data) || (data.length && typeof data[0] !== 'string'))
throw new Error('padding.encode input should be array of strings');
for (let i of data)
if (typeof i !== 'string') throw new Error(`padding.encode: non-string input=${i}`);
while ((data.length * bits) % 8) data.push(chr);
return data;
},
decode(input: string[]): string[] {
if (!Array.isArray(input) || (input.length && typeof input[0] !== 'string'))
throw new Error('padding.encode input should be array of strings');
for (let i of input)
if (typeof i !== 'string') throw new Error(`padding.decode: non-string input=${i}`);
let end = input.length;
if ((end * bits) % 8)
throw new Error('Invalid padding: string should have whole number of bytes');
for (; end > 0 && input[end - 1] === chr; end--) {
if (!(((end - 1) * bits) % 8))
throw new Error('Invalid padding: string has too much padding');
}
return input.slice(0, end);
},
};
}
function normalize<T>(fn: (val: T) => T): Coder<T, T> {
if (typeof fn !== 'function') throw new Error('normalize fn should be function');
return { encode: (from: T) => from, decode: (to: T) => fn(to) };
}
// NOTE: it has quadratic time complexity
function convertRadix(data: number[], from: number, to: number) {
// base 1 is impossible
if (from < 2) throw new Error(`convertRadix: wrong from=${from}, base cannot be less than 2`);
if (to < 2) throw new Error(`convertRadix: wrong to=${to}, base cannot be less than 2`);
if (!Array.isArray(data)) throw new Error('convertRadix: data should be array');
if (!data.length) return [];
let pos = 0;
const res = [];
const digits = Array.from(data);
digits.forEach((d) => {
assertNumber(d);
if (d < 0 || d >= from) throw new Error(`Wrong integer: ${d}`);
});
while (true) {
let carry = 0;
let done = true;
for (let i = pos; i < digits.length; i++) {
const digit = digits[i];
const digitBase = from * carry + digit;
if (
!Number.isSafeInteger(digitBase) ||
(from * carry) / from !== carry ||
digitBase - digit !== from * carry
) {
throw new Error('convertRadix: carry overflow');
}
carry = digitBase % to;
digits[i] = Math.floor(digitBase / to);
if (!Number.isSafeInteger(digits[i]) || digits[i] * to + carry !== digitBase)
throw new Error('convertRadix: carry overflow');
if (!done) continue;
else if (!digits[i]) pos = i;
else done = false;
}
res.push(carry);
if (done) break;
}
for (let i = 0; i < data.length - 1 && data[i] === 0; i++) res.push(0);
return res.reverse();
}
const gcd = (a: number, b: number): number => (!b ? a : gcd(b, a % b));
const radix2carry = (from: number, to: number) => from + (to - gcd(from, to));
// BigInt is 5x slower
function convertRadix2(data: number[], from: number, to: number, padding: boolean): number[] {
if (!Array.isArray(data)) throw new Error('convertRadix2: data should be array');
if (from <= 0 || from > 32) throw new Error(`convertRadix2: wrong from=${from}`);
if (to <= 0 || to > 32) throw new Error(`convertRadix2: wrong to=${to}`);
if (radix2carry(from, to) > 32) {
throw new Error(
`convertRadix2: carry overflow from=${from} to=${to} carryBits=${radix2carry(from, to)}`
);
}
let carry = 0;
let pos = 0; // bitwise position in current element
const mask = 2 ** to - 1;
const res: number[] = [];
for (const n of data) {
assertNumber(n);
if (n >= 2 ** from) throw new Error(`convertRadix2: invalid data word=${n} from=${from}`);
carry = (carry << from) | n;
if (pos + from > 32) throw new Error(`convertRadix2: carry overflow pos=${pos} from=${from}`);
pos += from;
for (; pos >= to; pos -= to) res.push(((carry >> (pos - to)) & mask) >>> 0);
carry &= 2 ** pos - 1; // clean carry, otherwise it will cause overflow
}
carry = (carry << (to - pos)) & mask;
if (!padding && pos >= from) throw new Error('Excess padding');
if (!padding && carry) throw new Error(`Non-zero padding: ${carry}`);
if (padding && pos > 0) res.push(carry >>> 0);
return res;
}
function radix(num: number): Coder<Uint8Array, number[]> {
assertNumber(num);
return {
encode: (bytes: Uint8Array) => {
if (!(bytes instanceof Uint8Array))
throw new Error('radix.encode input should be Uint8Array');
return convertRadix(Array.from(bytes), 2 ** 8, num);
},
decode: (digits: number[]) => {
if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== 'number'))
throw new Error('radix.decode input should be array of strings');
return Uint8Array.from(convertRadix(digits, num, 2 ** 8));
},
};
}
// If both bases are power of same number (like `2**8 <-> 2**64`),
// there is a linear algorithm. For now we have implementation for power-of-two bases only
function radix2(bits: number, revPadding = false): Coder<Uint8Array, number[]> {
assertNumber(bits);
if (bits <= 0 || bits > 32) throw new Error('radix2: bits should be in (0..32]');
if (radix2carry(8, bits) > 32 || radix2carry(bits, 8) > 32)
throw new Error('radix2: carry overflow');
return {
encode: (bytes: Uint8Array) => {
if (!(bytes instanceof Uint8Array))
throw new Error('radix2.encode input should be Uint8Array');
return convertRadix2(Array.from(bytes), 8, bits, !revPadding);
},
decode: (digits: number[]) => {
if (!Array.isArray(digits) || (digits.length && typeof digits[0] !== 'number'))
throw new Error('radix2.decode input should be array of strings');
return Uint8Array.from(convertRadix2(digits, bits, 8, revPadding));
},
};
}
type ArgumentTypes<F extends Function> = F extends (...args: infer A) => any ? A : never;
function unsafeWrapper<T extends (...args: any) => any>(fn: T) {
if (typeof fn !== 'function') throw new Error('unsafeWrapper fn should be function');
return function (...args: ArgumentTypes<T>): ReturnType<T> | undefined {
try {
return fn.apply(null, args);
} catch (e) {}
};
}
function checksum(
len: number,
fn: (data: Uint8Array) => Uint8Array
): Coder<Uint8Array, Uint8Array> {
assertNumber(len);
if (typeof fn !== 'function') throw new Error('checksum fn should be function');
return {
encode(data: Uint8Array) {
if (!(data instanceof Uint8Array))
throw new Error('checksum.encode: input should be Uint8Array');
const checksum = fn(data).slice(0, len);
const res = new Uint8Array(data.length + len);
res.set(data);
res.set(checksum, data.length);
return res;
},
decode(data: Uint8Array) {
if (!(data instanceof Uint8Array))
throw new Error('checksum.decode: input should be Uint8Array');
const payload = data.slice(0, -len);
const newChecksum = fn(payload).slice(0, len);
const oldChecksum = data.slice(-len);
for (let i = 0; i < len; i++)
if (newChecksum[i] !== oldChecksum[i]) throw new Error('Invalid checksum');
return payload;
},
};
}
const utils = { alphabet, chain, checksum, radix, radix2, join, padding };
// RFC 4648 aka RFC 3548
// ---------------------
const base16: BytesCoder = chain(radix2(4), alphabet('0123456789ABCDEF'), join(''));
const base32: BytesCoder = chain(
radix2(5),
alphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'),
padding(5),
join('')
);
const base32hex: BytesCoder = chain(
radix2(5),
alphabet('0123456789ABCDEFGHIJKLMNOPQRSTUV'),
padding(5),
join('')
);
const base32crockford: BytesCoder = chain(
radix2(5),
alphabet('0123456789ABCDEFGHJKMNPQRSTVWXYZ'),
join(''),
normalize((s: string) => s.toUpperCase().replace(/O/g, '0').replace(/[IL]/g, '1'))
);
const base64: BytesCoder = chain(
radix2(6),
alphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'),
padding(6),
join('')
);
const base64url: BytesCoder = chain(
radix2(6),
alphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'),
padding(6),
join('')
);
const base64urlnopad: BytesCoder = chain(
radix2(6),
alphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'),
join('')
);
// base58 code
// -----------
const genBase58 = (abc: string) => chain(radix(58), alphabet(abc), join(''));
const base58: BytesCoder = genBase58(
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
);
const base58flickr: BytesCoder = genBase58(
'123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
);
const base58xrp: BytesCoder = genBase58(
'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz'
);
// xmr ver is done in 8-byte blocks (which equals 11 chars in decoding). Last (non-full) block padded with '1' to size in XMR_BLOCK_LEN.
// Block encoding significantly reduces quadratic complexity of base58.
// Data len (index) -> encoded block len
const XMR_BLOCK_LEN = [0, 2, 3, 5, 6, 7, 9, 10, 11];
const base58xmr: BytesCoder = {
encode(data: Uint8Array) {
let res = '';
for (let i = 0; i < data.length; i += 8) {
const block = data.subarray(i, i + 8);
res += base58.encode(block).padStart(XMR_BLOCK_LEN[block.length], '1');
}
return res;
},
decode(str: string) {
let res: number[] = [];
for (let i = 0; i < str.length; i += 11) {
const slice = str.slice(i, i + 11);
const blockLen = XMR_BLOCK_LEN.indexOf(slice.length);
const block = base58.decode(slice);
for (let j = 0; j < block.length - blockLen; j++) {
if (block[j] !== 0) throw new Error('base58xmr: wrong padding');
}
res = res.concat(Array.from(block.slice(block.length - blockLen)));
}
return Uint8Array.from(res);
},
};
const base58check = (sha256: (data: Uint8Array) => Uint8Array): BytesCoder =>
chain(
checksum(4, (data) => sha256(sha256(data))),
base58
);
// Bech32 code
// -----------
interface Bech32Decoded {
prefix: string;
words: number[];
}
interface Bech32DecodedWithArray {
prefix: string;
words: number[];
bytes: Uint8Array;
}
const BECH_ALPHABET: Coder<number[], string> = chain(
alphabet('qpzry9x8gf2tvdw0s3jn54khce6mua7l'),
join('')
);
const POLYMOD_GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
function bech32Polymod(pre: number): number {
const b = pre >> 25;
let chk = (pre & 0x1ffffff) << 5;
for (let i = 0; i < POLYMOD_GENERATORS.length; i++) {
if (((b >> i) & 1) === 1) chk ^= POLYMOD_GENERATORS[i];
}
return chk;
}
function bechChecksum(prefix: string, words: number[], encodingConst = 1): string {
const len = prefix.length;
let chk = 1;
for (let i = 0; i < len; i++) {
const c = prefix.charCodeAt(i);
if (c < 33 || c > 126) throw new Error(`Invalid prefix (${prefix})`);
chk = bech32Polymod(chk) ^ (c >> 5);
}
chk = bech32Polymod(chk);
for (let i = 0; i < len; i++) chk = bech32Polymod(chk) ^ (prefix.charCodeAt(i) & 0x1f);
for (let v of words) chk = bech32Polymod(chk) ^ v;
for (let i = 0; i < 6; i++) chk = bech32Polymod(chk);
chk ^= encodingConst;
return BECH_ALPHABET.encode(convertRadix2([chk % 2 ** 30], 30, 5, false));
}
function genBech32(encoding: 'bech32' | 'bech32m') {
const ENCODING_CONST = encoding === 'bech32' ? 1 : 0x2bc830a3;
const _words = radix2(5);
const fromWords = _words.decode;
const toWords = _words.encode;
const fromWordsUnsafe = unsafeWrapper(fromWords);
function encode(
prefix: string,
words: number[] | Uint8Array,
limit: number | false = 90
): string {
if (typeof prefix !== 'string')
throw new Error(`bech32.encode prefix should be string, not ${typeof prefix}`);
if (!Array.isArray(words) || (words.length && typeof words[0] !== 'number'))
throw new Error(`bech32.encode words should be array of numbers, not ${typeof words}`);
const actualLength = prefix.length + 7 + words.length;
if (limit !== false && actualLength > limit)
throw new TypeError(`Length ${actualLength} exceeds limit ${limit}`);
prefix = prefix.toLowerCase();
return `${prefix}1${BECH_ALPHABET.encode(words)}${bechChecksum(prefix, words, ENCODING_CONST)}`;
}
function decode(str: string, limit: number | false = 90): Bech32Decoded {
if (typeof str !== 'string')
throw new Error(`bech32.decode input should be string, not ${typeof str}`);
if (str.length < 8 || (limit !== false && str.length > limit))
throw new TypeError(`Wrong string length: ${str.length} (${str}). Expected (8..${limit})`);
// don't allow mixed case
const lowered = str.toLowerCase();
if (str !== lowered && str !== str.toUpperCase())
throw new Error(`String must be lowercase or uppercase`);
str = lowered;
const sepIndex = str.lastIndexOf('1');
if (sepIndex === 0 || sepIndex === -1)
throw new Error(`Letter "1" must be present between prefix and data only`);
const prefix = str.slice(0, sepIndex);
const _words = str.slice(sepIndex + 1);
if (_words.length < 6) throw new Error('Data must be at least 6 characters long');
const words = BECH_ALPHABET.decode(_words).slice(0, -6);
const sum = bechChecksum(prefix, words, ENCODING_CONST);
if (!_words.endsWith(sum)) throw new Error(`Invalid checksum in ${str}: expected "${sum}"`);
return { prefix, words };
}
const decodeUnsafe = unsafeWrapper(decode);
function decodeToBytes(str: string): Bech32DecodedWithArray {
const { prefix, words } = decode(str, false);
return { prefix, words, bytes: fromWords(words) };
}
return { encode, decode, decodeToBytes, decodeUnsafe, fromWords, fromWordsUnsafe, toWords };
}
const bech32 = genBech32('bech32');
const bech32m = genBech32('bech32m');
declare const TextEncoder: any;
declare const TextDecoder: any;
const utf8: BytesCoder = {
encode: (data) => new TextDecoder().decode(data),
decode: (str) => new TextEncoder().encode(str),
};
const hex: BytesCoder = chain(
radix2(4),
alphabet('0123456789abcdef'),
join(''),
normalize((s: string) => {
if (typeof s !== 'string' || s.length % 2)
throw new TypeError(`hex.decode: expected string, got ${typeof s} with length ${s.length}`);
return s.toLowerCase();
})
);
// prettier-ignore
const CODERS = {
utf8, hex, base16, base32, base64, base64url, base58, base58xmr
};
type CoderType = keyof typeof CODERS;
const coderTypeError = `Invalid encoding type. Available types: ${Object.keys(CODERS).join(', ')}`;
const bytesToString = (type: CoderType, bytes: Uint8Array): string => {
if (typeof type !== 'string' || !CODERS.hasOwnProperty(type)) throw new TypeError(coderTypeError);
if (!(bytes instanceof Uint8Array)) throw new TypeError('bytesToString() expects Uint8Array');
return CODERS[type].encode(bytes);
};
const str = bytesToString; // as in python, but for bytes only
const stringToBytes = (type: CoderType, str: string): Uint8Array => {
if (!CODERS.hasOwnProperty(type)) throw new TypeError(coderTypeError);
if (typeof str !== 'string') throw new TypeError('stringToBytes() expects string');
return CODERS[type].decode(str);
};
const bytes = stringToBytes;

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="oddbean.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.49497475"
inkscape:cx="-192.29871"
inkscape:cy="845.12978"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1025"
inkscape:window-x="0"
inkscape:window-y="55"
inkscape:window-maximized="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -50.754379,128.99284 c -8.82989,13.94438 -27.50467,17.5232 -50.066461,8.81159 -20.31758,-7.84507 -45.157,-28.42422 -41.0243,-43.580741 0.23443,-0.85977 0.33992,-1.72781 0.62868,-2.5326"
id="path833-3"
sodipodi:nodetypes="cssc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -141.21646,91.691089 c 3.67802,-11.509966 11.10032,-15.262961 18.12405,-15.939971 8.36266,-0.806064 17.48638,4.642772 25.115361,15.212391"
id="path879"
sodipodi:nodetypes="csc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -97.977049,90.963509 c 5.21962,6.143289 11.68687,11.001131 22.74067,11.133451"
id="path885"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -75.236379,102.09696 c 13.36271,-0.77627 22.9275,1.40856 26.09249,8.58306 1.86632,4.23063 4.08831,10.15711 -1.61049,18.31282"
id="path887"
sodipodi:nodetypes="csc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -97.977049,90.963509 c 1.35886,14.771351 8.55524,19.164891 22.74067,11.133451"
id="path907"
sodipodi:nodetypes="cc" />
<path
style="fill:#65cb25;fill-opacity:1;stroke:#d40000;stroke-width:0.0300019"
d="m -33.233488,251.50846 c -8.813998,-6.80791 -14.918872,-13.25072 -20.347464,-21.4738 -4.64414,-7.03481 -7.85784,-14.78195 -9.10464,-21.94818 -1.53399,-8.81692 0.0711,-16.50124 4.4825,-21.45935 3.89369,-4.37629 6.91821,-6.83757 10.004289,-8.14127 2.747228,-1.16055 6.427196,-1.53651 10.019857,-1.0237 3.089093,0.44093 3.790402,0.70096 7.201408,2.67013 3.09676,1.78777 3.115204,1.80088 4.352216,3.23406 4.043398,4.68241 6.422039,10.14795 7.699308,17.69115 0.529457,3.12683 0.513309,3.5864 -0.182222,5.18652 -1.107697,2.54834 -1.968069,6.23099 -2.009629,8.60185 -0.03066,1.74876 0.603035,3.97222 1.577973,5.53674 0.799735,1.28334 0.816007,1.29765 2.984786,2.62566 1.787182,1.0943 2.474335,1.42279 3.807311,1.82144 1.710211,0.51113 4.380361,0.90015 7.396515,1.07764 l 1.801621,0.10601 4.663687,2.86985 c 5.18973,3.19358 6.51538,4.23386 9.11057,7.14945 1.85162,2.08022 2.67282,3.54489 3.31704,5.91618 0.58639,2.15845 0.59696,4.27577 0.0339,6.7845 -0.2909,1.29598 -0.50499,1.75975 -2.02828,4.39371 l -1.70297,2.94484 -1.60945,1.39707 c -2.96457,2.5734 -5.3847,3.93613 -8.39391701,4.72647 -4.88184699,1.28218 -10.71447299,0.66084 -17.64596699,-1.87977 -1.925927,-0.70592 -3.012884,-1.25468 -7.519274,-3.79618 -4.79454,-2.70404 -5.505202,-3.1543 -7.909035,-5.01097 z"
id="path913-7" />
<path
style="fill:#008000;fill-opacity:1;stroke:#d40000;stroke-width:0.0300019"
d="m -17.859702,221.31694 c -1.206272,-1.19157 -1.830623,-2.34263 -2.233889,-4.11843 -0.657676,-2.89609 -0.166771,-5.99779 1.761864,-11.13211 l 0.266091,-0.70838 0.895146,3.12461 c 1.313919,4.5864 3.001403,7.859 5.644504,10.9466 1.422997,1.66232 3.65816,3.65297 5.507199,4.90474 l 1.452528,0.98335 -2.299773,-0.17146 c -5.143434,-0.38346 -8.79214,-1.65425 -10.99367,-3.82892 z"
id="path915-5" />
<path
style="fill:#65cb25;fill-opacity:1;stroke:#d40000;stroke-width:0.0424291"
d="m -84.809093,141.7627 c -12.939003,-1.80808 -26.504567,-7.92384 -39.021357,-17.59198 -3.84307,-2.96845 -10.65425,-9.84228 -12.88289,-13.0014 -4.7306,-6.70566 -6.13333,-12.021971 -4.66627,-17.685105 2.34804,-9.063945 7.81837,-15.137384 15.27738,-16.96171 2.57501,-0.629798 6.94851,-0.619849 9.22079,0.02098 5.75602,1.623301 11.71755,5.906833 16.50634,11.8603 l 2.107502,2.620065 0.32593,2.329852 c 0.8294,5.92882 3.165499,10.333028 6.315287,11.906108 3.647638,1.82172 8.043272,1.28724 14.260803,-1.73402 l 2.499027,-1.21434 4.583615,0.003 c 11.575203,0.008 17.880766,2.56394 20.75561,8.41453 3.253274,6.62073 2.775341,11.70725 -1.737255,18.48926 -5.702688,8.57062 -14.956771,13.0084 -26.956151,12.92679 -2.227117,-0.0152 -5.191879,-0.1872 -6.588361,-0.38234 z"
id="path987" />
<path
style="fill:#008000;fill-opacity:1;stroke:#d40000;stroke-width:0.0424291"
d="m -89.219041,105.59271 c -2.639375,-0.69588 -4.933365,-2.69171 -6.239549,-5.42858 -0.753925,-1.579703 -1.561166,-4.456637 -2.040374,-7.271708 l -0.176702,-1.038008 2.200896,2.186159 c 4.728348,4.696688 9.931228,7.228037 16.566385,8.060017 1.231059,0.15436 2.273491,0.31586 2.316515,0.35888 0.154514,0.15451 -4.113168,1.98255 -5.945434,2.54669 -2.036074,0.62689 -5.406535,0.92276 -6.681737,0.58655 z"
id="path989" />
<path
style="fill:#65cb25;fill-opacity:1;stroke:#d40000;stroke-width:0.0424291"
d="m 140.86252,210.76853 c -12.03015,-5.09533 -23.55061,-14.51373 -33.13859,-27.09201 -2.94383,-3.86197 -7.74385,-12.26444 -9.078917,-15.89274 -2.833856,-7.70153 -2.812822,-13.19974 0.06997,-18.29022 4.613958,-8.14737 11.469818,-12.59804 19.146828,-12.42967 2.65028,0.0581 6.87218,1.19967 8.90118,2.4068 5.13974,3.05776 9.78947,8.73829 12.87422,15.72832 l 1.35757,3.07626 -0.28819,2.33482 c -0.73336,5.94146 0.38325,10.80023 3.01857,13.13493 3.05185,2.70373 7.43604,3.32513 14.22367,2.01603 l 2.72817,-0.52616 4.42666,1.18922 c 11.17872,3.00361 16.6079,7.10446 17.87054,13.49976 1.42885,7.23715 -0.34929,12.02666 -6.46344,17.40963 -7.72661,6.80262 -17.81395,8.69405 -29.38334,5.50956 -2.1473,-0.59111 -4.96652,-1.52458 -6.26491,-2.07451 z"
id="path987-5" />
<path
style="fill:#008000;fill-opacity:1;stroke:#d40000;stroke-width:0.0424291"
d="m 146.51794,174.46178 c -2.36934,-1.35529 -4.0686,-3.87684 -4.62192,-6.85852 -0.31938,-1.72101 -0.35451,-4.70884 -0.0888,-7.55202 l 0.098,-1.04838 1.56008,2.68131 c 3.35165,5.76044 7.72208,9.55214 13.91581,12.07307 1.14916,0.46773 2.11427,0.89352 2.1447,0.94622 0.10926,0.18923 -4.48613,0.85042 -6.40197,0.92111 -2.12895,0.0786 -5.46115,-0.50799 -6.60588,-1.16279 z"
id="path989-6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,301 @@
/* general */
@media (min-width: 300px) and (max-width: 750px) {
body {
padding: 0;
margin: 0;
width: 100%;
}
}
@media (min-width: 751px) {
body {
width: 85%;
margin: 8px;
margin-left: auto;
margin-right: auto;
}
}
body {
font-family: sans-serif;
}
a:link, a:visited {
text-decoration: none;
color: green;
}
a:hover {
text-decoration: underline;
}
pre {
white-space: pre-line;
}
h2 {
font-size: 125%;
margin-bottom: 20px;
}
/* header */
#ob-header {
background-color: #65cb25;
height: 26px;
padding: 2px;
display: flex;
align-items: center;
justify-content: space-between;
img.logo {
background-color: white;
padding: 2px;
border: 2px solid green;
height: 18px;
}
.links {
display: flex;
align-items: center;
.sitename {
color: black;
}
.oddbean-name {
font-weight: bold;
margin-left: 5px;
}
.community-rules {
font-size: 70%;
color: darkmagenta;
margin-left: 5px;
}
.new-post {
margin-left: 20px;
}
}
.login {
margin-right: 5px;
}
a:link, a:visited {
color: black;
}
.logout-link:hover {
text-decoration: underline;
}
}
#ob-page {
padding: 10px 10px 10px 10px;
background-color: #f6f6ef;
}
#ob-footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #65cb25;
text-align: center;
}
table {
width: 100%;
th {
color: white;
background-color: black;
}
tr:nth-child(odd) {
background-color: #fefefe;
}
tr:nth-child(even) {
background-color: #f6f6ef;
}
td,th {
padding: 8px 5px 8px 5px;
}
td {
line-break: anywhere;
}
}
table.horiz {
tr td:nth-child(1) {
text-align: right;
padding-right: 20px;
}
tr td:nth-child(2) {
padding-left: 15px;
}
}
table.vert {
tr td:nth-child(1) {
text-align: center;
}
/*
tr td:nth-child(2) {
padding-left: 15px;
}
*/
}
.user-metadata {
.user-links {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: space-evenly;
> a {
margin: 3px;
}
}
}
/* user comments */
.user-comments > div {
margin-bottom: 30px;
border-bottom: 1px solid black;
}
/* event */
.event {
margin-bottom: 20px;
overflow-x: clip;
.event-present {
display: flex;
flex-direction: row;
}
.event-not-present {
color: red;
}
.header {
}
.highlight {
font-weight: bold;
}
.abbrev {
}
.content {
padding-left: 25px;
}
}
.event-root {
.replies {
margin-top: 20px;
padding-left: 20px;
border-left: 1px dotted black;
}
}
/* new post */
.new-post {
.submit-button {
margin-top: 20px;
margin-bottom: 20px;
}
.result-message {
color: red;
text-weight: bold;
}
}
/* communities */
.community-item {
margin-bottom: 8px;
overflow: hidden;
display: flex;
flex-direction: row;
.left {
padding-right: 5px;
color: #828282;
}
.summary > a:link, .summary > a:visited {
text-decoration: none;
color: black;
}
.info {
padding-top: 5px;
font-size: 75%;
color: #828282;
}
}
.community-info {
h2 {
margin-bottom: 10px;
font-size: 110%;
font-weight: bold;
}
> div {
padding-left: 5px;
margin-bottom: 25px;
}
.desc {
white-space: pre-line;
}
.algo {
white-space: pre;
font-family: monospace;
margin-top: 25px;
padding: 10px 10px 10px 10px;
background-color: lightgoldenrodyellow;
}
}
/* voting */
.vote-buttons {
padding-right: 5px;
color: #828282;
font-size: 16px;
justify-content: flex-start;
display: flex;
flex-direction: column;
> span {
margin-bottom: 2px;
}
}

View File

@ -0,0 +1,153 @@
const bech32Ctx = genBech32('bech32');
function encodeBech32(prefix, inp) {
return bech32Ctx.encode(prefix, bech32Ctx.toWords(base16.decode(inp.toUpperCase())));
}
function decodeBech32(inp) {
return base16.encode(bech32Ctx.fromWords(bech32Ctx.decode(inp).words)).toLowerCase();
}
document.addEventListener('alpine:init', () => {
Alpine.data('obLogin', () => ({
loggedIn: false,
pubkey: '',
username: '',
init() {
let storage = JSON.parse(window.localStorage.getItem('auth') || '{}');
if (storage.pubkey) {
this.loggedIn = true;
this.pubkey = storage.pubkey;
this.username = storage.username;
}
},
async login() {
let pubkey = await nostr.getPublicKey();
let response = await fetch(`/u/${pubkey}/metadata.json`);
let json = await response.json();
console.log(json);
let username = json.name;
if (username === undefined) username = pubkey.substr(0, 8) + '...';
if (username.length > 25) username = username.substr(0, 25) + '...';
this.pubkey = pubkey;
this.username = username;
window.localStorage.setItem('auth', JSON.stringify({ pubkey, username, }));
this.loggedIn = true;
},
myProfile() {
return `/u/${encodeBech32('npub', this.pubkey)}`;
},
logout() {
window.localStorage.setItem('auth', '{}');
this.loggedIn = false;
},
}));
Alpine.data('newPost', () => ({
init() {
},
async submit() {
this.$refs.msg.innerText = '';
let ev = {
created_at: Math.floor(((new Date()) - 0) / 1000),
kind: 1,
tags: [],
content: this.$refs.post.value,
};
ev = await window.nostr.signEvent(ev);
let resp = await fetch("/submit-post", {
method: "post",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(ev),
});
let json = await resp.json();
if (json.message === 'ok' && json.written === true) {
window.location = `/e/${json.event}`
} else {
this.$refs.msg.innerText = `Sending note failed: ${json.message}`;
console.error(json);
}
},
}))
});
document.addEventListener("click", async (e) => {
let parent = e.target.closest(".vote-context");
if (!parent) return;
let which = e.target.className;
if (which.length !== 1) return;
let note = parent.getAttribute('data-note');
e.target.className = 'loading';
e.target.innerText = '↻';
if (which === 'f') return; // not impl
try {
let ev = {
created_at: Math.floor(((new Date()) - 0) / 1000),
kind: 7,
tags: [],
content: which === 'u' ? '+' : '-',
};
{
let response = await fetch(`/e/${note}/raw.json`);
let liked = await response.json();
for (let tag of liked.tags) {
if (tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p')) ev.tags.push(tag);
}
ev.tags.push(['e', liked.id]);
ev.tags.push(['p', liked.pubkey]);
}
ev = await window.nostr.signEvent(ev);
let response = await fetch("/submit-post", {
method: "post",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(ev),
});
let json = await response.json();
if (json.message === 'ok' && json.written === true) {
e.target.className = 'success';
e.target.innerText = '✔';
} else {
throw(Error(`Sending reaction note failed: ${json.message}`));
console.error(json);
}
} catch(err) {
console.error(err);
e.target.className = 'error';
e.target.innerText = '✘';
}
});

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="328.61371"
height="292.26895"
viewBox="0 0 86.945709 77.329492"
version="1.1"
id="svg859"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="oddbean.svg">
<defs
id="defs853" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="170.24797"
inkscape:cy="218.68817"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1421"
inkscape:window-height="1021"
inkscape:window-x="12"
inkscape:window-y="55"
inkscape:window-maximized="0" />
<metadata
id="metadata856">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-63.116427,-31.638828)">
<path
style="fill:#65cb25;fill-opacity:1;stroke:#d40000;stroke-width:0.0424291"
d="M 107.46364,105.36929 C 95.433493,100.27396 83.913033,90.85556 74.325053,78.27728 71.381223,74.41531 66.581203,66.01284 65.246136,62.38454 62.41228,54.68301 62.433314,49.1848 65.316106,44.09432 69.930063,35.94695 76.785923,31.49628 84.462933,31.66465 c 2.65028,0.0581 6.87218,1.19967 8.90118,2.4068 5.13974,3.05776 9.789467,8.73829 12.874217,15.72832 l 1.35757,3.07626 -0.28819,2.33482 c -0.73336,5.94146 0.38325,10.80023 3.01857,13.13493 3.05185,2.70373 7.43604,3.32513 14.22367,2.01603 l 2.72817,-0.52616 4.42666,1.18922 c 11.17872,3.00361 16.6079,7.10446 17.87054,13.49976 1.42885,7.23715 -0.34929,12.02666 -6.46344,17.40963 -7.72661,6.80262 -17.81395,8.69405 -29.38334,5.50956 -2.1473,-0.59111 -4.96652,-1.52458 -6.26491,-2.07451 z"
id="path987-5" />
<path
style="fill:#008000;fill-opacity:1;stroke:#d40000;stroke-width:0.0677551"
d="m 112.734,71.438599 c -3.91726,-2.090422 -6.72667,-5.979702 -7.64148,-10.578695 -0.52804,-2.654514 -0.58612,-7.262992 -0.14682,-11.64836 l 0.16203,-1.617039 2.5793,4.135697 c 5.54133,8.884998 12.76702,14.733379 23.00719,18.621703 1.89992,0.721434 3.49555,1.37818 3.54586,1.459465 0.18064,0.291871 -7.41698,1.311702 -10.58446,1.420735 -3.51982,0.121234 -9.02899,-0.783532 -10.92159,-1.793506 z"
id="path989-6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,43 @@
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

3998
src/apps/web/static/turbo.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
<div class="community-info">
<h2>Community: $(ctx.communitySpec.name)</h2>
<div>
Admin: <a href='/u/$(ctx.communitySpec.adminNpub)'>$(ctx.communitySpec.adminUsername)</a> ($(ctx.communitySpec.adminTopic))
</div>
<h2>Description</h2>
<div class="desc">$(ctx.communitySpec.desc)</div>
<h2>Algo</h2>
<div>
<pre class="algo">$(ctx.communitySpec.algo)</pre>
</div>
</div>

View File

@ -0,0 +1,24 @@
<div class="community-item vote-context" data-note="$(ctx.ev.getNoteId())">
<div class="left">
<div>$(ctx.n).</div>
</div>
<div class="vote-buttons">
<span class="u">▲</span>
<span class="d">▼</span>
</div>
<div class="right">
<div class="summary">
<a href="/e/$(ctx.ev.getNoteId())">$!(ctx.ev.summaryHtml())</a>
</div>
<div class="info">
<span>$(renderPoints(ctx.info.score))</span> points by
<a href="/u/$(ctx.user.npubId)">$(ctx.user.username)</a>
$(ctx.timestamp) |
<span class="f">flag</span> |
<a href="/e/$(ctx.ev.getNoteId())">$(ctx.info.comments) comments</a>
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
<div> @(const auto &r : ctx)
$(r)
</div>

View File

@ -0,0 +1,43 @@
<div class='event $(ctx.abbrev ? "abbrev" : "")'>
<> ?(ctx.eventPresent)
<div class="event-present vote-context" data-note="$(ctx.ev->getNoteId())">
<div class="vote-buttons">
<span class="u">▲</span>
<span class="d">▼</span>
</div>
<div class="header">
<a class='$(ctx.highlight ? "highlight" : "")' href="/u/$(ctx.user->npubId)">$(ctx.user->username)</a>
| <a href="/e/$(ctx.ev->getNoteId())">$(ctx.timestamp)</a>
(<a href="/e/$(ctx.ev->getNoteId())/raw.json">raw</a>)
<> | <a href="/e/$(ctx.ev->getRootNoteId())">root</a> </> ?(ctx.ev->root.size())
<> | <a href="/e/$(ctx.ev->getNoteId())">show full thread</a> </> ?(ctx.ev->root.empty() && !ctx.isFullThreadLoaded)
<> | <a href="/e/$(ctx.ev->getNoteId())/export.jsonl">export</a> </> ?(ctx.ev->root.empty() && ctx.isFullThreadLoaded)
<> | <a href="/e/$(ctx.ev->getParentNoteId())">parent</a> </> ?(ctx.ev->parent.size())
<br/>
<a href="">reply</a>
| <a href="" class="f">flag</a>
<>+$(ctx.ev->upVotes)</> ?(ctx.ev->upVotes)
<>-$(ctx.ev->downVotes)</> ?(ctx.ev->downVotes)
</div>
</div>
<pre class="content">
$!(ctx.content)
</pre>
</>
<> ?(!ctx.eventPresent)
<div class="event-not-present">Event not found</div>
</>
<div class="replies">
<div> @(const auto &r : ctx.replies)
$(r.rendered)
</div>
</div> ?(ctx.replies.size())
</div>

View File

@ -0,0 +1,7 @@
<div class="event-root">
$(ctx.foundEvents)
<div class="replies"> @(auto &e : ctx.orphanNodes)
$(e.rendered)
</div>
</div>

View File

@ -0,0 +1,47 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="$(ctx.staticFilesPrefix)/oddbean.css" rel="stylesheet">
<script defer src="$(ctx.staticFilesPrefix)/oddbean.js"></script>
<title>$(ctx.title)Oddbean</title>
</head>
<body>
<div id="ob-header">
<span class="links">
<a href="/"><img class="logo" src="$(ctx.staticFilesPrefix)/oddbean.svg"></a>
<a href="/" class="sitename">
<span class="oddbean-name">Oddbean</span>
<> ?(ctx.communitySpec)
/ $(ctx.communitySpec->name)
</>
</a>
<a href="/algo" class="community-rules">(algo)</a> ?(ctx.communitySpec)
<a href="/post" class="new-post">new post</a>
</span>
<span x-data="obLogin" class="login">
<span x-show="!loggedIn" @click="login" class="login-link">login</span>
<span x-show="loggedIn">
<a x-text="username" class="username" :href="myProfile"></a>
| <span @click="logout" class="logout-link">logout</span>
</span>
</span>
</div>
<div id="ob-page">
$(ctx.body)
</div>
<div id="ob-footer">
<form method="get" action="/search">
Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false">
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
<div x-data="newPost" class="new-post">
<textarea id="post-text" name="post-text" x-ref="post" rows="8" cols="80" wrap="virtual"></textarea>
<div @click="submit" class="submit-button">
<button>Post Note</button>
</div>
<div class="result-message" x-ref="msg">
</div>
</div>

View File

@ -0,0 +1,6 @@
$;(
auto &e = ctx;
auto noteId = e.getNoteId();
)
Note: <a href="/e/$(noteId)">$(noteId)</a>

View File

@ -0,0 +1,3 @@
$;( auto &u = ctx; )
User: <a href="/u/$(u.npubId)">$(u.username)</a>

View File

@ -0,0 +1,3 @@
<div> @(const auto &r : ctx.results)
$(r)
</div>

View File

@ -0,0 +1,11 @@
<div class="user-comments">
<h2>
Notes by <a href="/u/$(ctx.u.npubId)">$(ctx.u.username)</a>
| <a href="/u/$(ctx.u.npubId)/export.jsonl">export</a>
| <a href="/u/$(ctx.u.npubId)/rss.xml">rss</a>
</h2>
<div> @(auto &r : ctx.renderedThreads)
$(r)
</div>
</div>

View File

@ -0,0 +1,19 @@
<div class="user-followers">
<table class="vert">
<tr>
<th>user</th>
</tr>
<> @(const auto &pubkey : ctx.followers)
$;(
auto *u = ctx.getUser(pubkey);
)
<tr>
<td>
<a href="/u/$(u->npubId)">$(u->username)</a>
</td>
</tr>
</>
</table>
</div>

View File

@ -0,0 +1,47 @@
<div class="user-following">
<div> ?(!ctx.user.kind3Event)
No kind 3 contact list found for $(ctx.user.npubId)
</div>
<table class="vert"> ?(ctx.user.kind3Event)
<tr>
<th>user</th>
<th>main relay</th>
<th>petname</th>
</tr>
<> @(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();
)
<tr>
<td>
<a href="/u/$(npubId)">$(username)</a>
</td>
<td>
$(relay)
</td>
<td>
$(petname)
</td>
</tr>
</>
</table>
</div>

View File

@ -0,0 +1,33 @@
<div class="user-metadata">
<div> ?(!ctx.kind0Found())
No metadata event found for $(ctx.npubId)
</div>
<table class="horiz"> ?(ctx.kind0Found())
<tr>
<th>npub</th>
<td>$(ctx.npubId)</td>
</tr>
<tr>
<th>$(field)</th>
<td>$(ctx.getMeta(field))</td>
</tr> @(const auto &field : { "name", "about" })
<>
<tr>
<th>$(field)</th>
<td>$(ctx.getMeta(field))</td>
</tr> ?(field != "name" && field != "about")
</> @(const auto &[field, v] : ctx.kind0Json->get_object())
</table>
<div class="user-links">
<a href="/u/$(ctx.npubId)/notes">notes</a>
<a href="/u/$(ctx.npubId)/following">following</a>
<a href="/u/$(ctx.npubId)/followers">followers</a>
<a href="/u/$(ctx.npubId)/export.jsonl">export</a>
<a href="/u/$(ctx.npubId)/rss.xml">rss</a>
</div>
</div>

View File

@ -139,3 +139,7 @@ relay {
maxSyncEvents = 1000000
}
}
web {
homepageCommunity = "npub1qqa6nvk9hk90am2p5n8rv25t0lp6kkwztd4pxkw2ayyn72td4sqsy8jzyj/homepage"
}