mirror of
https://github.com/hoytech/strfry.git
synced 2025-06-17 08:48:51 +00:00
web wip
This commit is contained in:
2
Makefile
2
Makefile
@ -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
435
src/apps/web/AlgoParser.h
Normal 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
110
src/apps/web/AlgoScanner.h
Normal 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;
|
||||
});
|
||||
}
|
||||
};
|
55
src/apps/web/Bech32Utils.h
Normal file
55
src/apps/web/Bech32Utils.h
Normal 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
89
src/apps/web/HTTP.h
Normal 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
11
src/apps/web/README
Normal 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
27
src/apps/web/TODO
Normal 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
676
src/apps/web/WebData.h
Normal 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;
|
||||
}
|
148
src/apps/web/WebHttpsocket.cpp
Normal file
148
src/apps/web/WebHttpsocket.cpp
Normal 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
432
src/apps/web/WebReader.cpp
Normal 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
140
src/apps/web/WebServer.h
Normal 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
56
src/apps/web/WebUtils.h
Normal 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);
|
||||
}
|
92
src/apps/web/WebWriter.cpp
Normal file
92
src/apps/web/WebWriter.cpp
Normal 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
229
src/apps/web/bech32.cpp
Normal 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
61
src/apps/web/bech32.h
Normal 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
56
src/apps/web/cmd_algo.cpp
Normal 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
39
src/apps/web/cmd_web.cpp
Normal 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
12
src/apps/web/golpe.yaml
Normal 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: ""
|
19
src/apps/web/homepage-community.yaml
Normal file
19
src/apps/web/homepage-community.yaml
Normal 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/;
|
||||
}
|
1
src/apps/web/install-homepage-community.sh
Normal file
1
src/apps/web/install-homepage-community.sh
Normal 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
6
src/apps/web/rules.mk
Normal 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
1
src/apps/web/static/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build/
|
19
src/apps/web/static/Makefile
Normal file
19
src/apps/web/static/Makefile
Normal 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/
|
5
src/apps/web/static/alpine.js
Normal file
5
src/apps/web/static/alpine.js
Normal file
File diff suppressed because one or more lines are too long
507
src/apps/web/static/base.ts
Normal file
507
src/apps/web/static/base.ts
Normal 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;
|
104
src/apps/web/static/experiments.svg
Normal file
104
src/apps/web/static/experiments.svg
Normal 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 |
301
src/apps/web/static/oddbean.css
Normal file
301
src/apps/web/static/oddbean.css
Normal 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;
|
||||
}
|
||||
}
|
153
src/apps/web/static/oddbean.js
Normal file
153
src/apps/web/static/oddbean.js
Normal 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 = '✘';
|
||||
}
|
||||
});
|
69
src/apps/web/static/oddbean.svg
Normal file
69
src/apps/web/static/oddbean.svg
Normal 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 |
43
src/apps/web/static/reset.css
Normal file
43
src/apps/web/static/reset.css
Normal 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
3998
src/apps/web/static/turbo.js
Normal file
File diff suppressed because it is too large
Load Diff
19
src/apps/web/tmpls/community/communityInfo.tmpl
Normal file
19
src/apps/web/tmpls/community/communityInfo.tmpl
Normal 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>
|
24
src/apps/web/tmpls/community/item.tmpl
Normal file
24
src/apps/web/tmpls/community/item.tmpl
Normal 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>
|
3
src/apps/web/tmpls/community/list.tmpl
Normal file
3
src/apps/web/tmpls/community/list.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
<div> @(const auto &r : ctx)
|
||||
$(r)
|
||||
</div>
|
43
src/apps/web/tmpls/event/event.tmpl
Normal file
43
src/apps/web/tmpls/event/event.tmpl
Normal 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>
|
7
src/apps/web/tmpls/events.tmpl
Normal file
7
src/apps/web/tmpls/events.tmpl
Normal file
@ -0,0 +1,7 @@
|
||||
<div class="event-root">
|
||||
$(ctx.foundEvents)
|
||||
|
||||
<div class="replies"> @(auto &e : ctx.orphanNodes)
|
||||
$(e.rendered)
|
||||
</div>
|
||||
</div>
|
47
src/apps/web/tmpls/main.tmpl
Normal file
47
src/apps/web/tmpls/main.tmpl
Normal 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>
|
10
src/apps/web/tmpls/newPost.tmpl
Normal file
10
src/apps/web/tmpls/newPost.tmpl
Normal 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>
|
6
src/apps/web/tmpls/search/eventResult.tmpl
Normal file
6
src/apps/web/tmpls/search/eventResult.tmpl
Normal file
@ -0,0 +1,6 @@
|
||||
$;(
|
||||
auto &e = ctx;
|
||||
auto noteId = e.getNoteId();
|
||||
)
|
||||
|
||||
Note: <a href="/e/$(noteId)">$(noteId)</a>
|
3
src/apps/web/tmpls/search/userResult.tmpl
Normal file
3
src/apps/web/tmpls/search/userResult.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
$;( auto &u = ctx; )
|
||||
|
||||
User: <a href="/u/$(u.npubId)">$(u.username)</a>
|
3
src/apps/web/tmpls/searchPage.tmpl
Normal file
3
src/apps/web/tmpls/searchPage.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
<div> @(const auto &r : ctx.results)
|
||||
$(r)
|
||||
</div>
|
11
src/apps/web/tmpls/user/comments.tmpl
Normal file
11
src/apps/web/tmpls/user/comments.tmpl
Normal 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>
|
19
src/apps/web/tmpls/user/followers.tmpl
Normal file
19
src/apps/web/tmpls/user/followers.tmpl
Normal 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>
|
47
src/apps/web/tmpls/user/following.tmpl
Normal file
47
src/apps/web/tmpls/user/following.tmpl
Normal 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>
|
33
src/apps/web/tmpls/user/metadata.tmpl
Normal file
33
src/apps/web/tmpls/user/metadata.tmpl
Normal 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>
|
@ -139,3 +139,7 @@ relay {
|
||||
maxSyncEvents = 1000000
|
||||
}
|
||||
}
|
||||
|
||||
web {
|
||||
homepageCommunity = "npub1qqa6nvk9hk90am2p5n8rv25t0lp6kkwztd4pxkw2ayyn72td4sqsy8jzyj/homepage"
|
||||
}
|
||||
|
Reference in New Issue
Block a user