// sinja: ultra-fast JSON templating REST server // Only dependency: nlohmann::json // Build: see CMakeLists.txt // Run : ./sinja --templates /path/to/templates --address 0.0.0.0 --port 8083 --threads $(nproc) #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using json = nlohmann::json; namespace fs = std::filesystem; // ---- Tunables ---------------------------------------------------------------- static constexpr const char* kServerName = "sinja/1.0"; static constexpr size_t kMaxBodyBytes = 8 * 1024 * 1024; // 8 MiB static constexpr size_t kMaxHeaderBytes = 64 * 1024; // 64 KiB static constexpr int kRecvTimeoutSec = 5; // per recv() static constexpr int kSendTimeoutSec = 5; static constexpr int kBacklog = 1024; // ---- Config ------------------------------------------------------------------ struct Settings { std::string address = "0.0.0.0"; int port = 8080; size_t threads = std::thread::hardware_concurrency() ? std::thread::hardware_concurrency() : 4; fs::path template_root; }; // ---- Small utils ------------------------------------------------------------- static inline std::string ltrim(std::string s){ size_t i=0; while(i=p.size() && std::equal(p.begin(), p.end(), s.begin()); } static std::string to_lower(std::string s){ for (auto& c: s) c = (char)std::tolower((unsigned char)c); return s; } // ---- Secure template resolution ---------------------------------------------- static fs::path secure_resolve_template(const fs::path& root, const std::string& req, std::string& reason) { if (req.empty()) { reason = "empty template name"; return {}; } fs::path rel(req); if (rel.is_absolute()) { reason = "absolute path not allowed"; return {}; } fs::path cand = fs::weakly_canonical(root / rel); fs::path canon_root = fs::weakly_canonical(root); std::string c1 = cand.string(), c0 = canon_root.string(); if (!starts_with(c1, c0)) { reason = "path traversal rejected"; return {}; } if (!fs::exists(cand) || !fs::is_regular_file(cand)) { reason = "template file not found"; return {}; } return cand; } // ---- Mini Inja (subset): {{ a.b[0].c }} and trims {{- ... -}} ---------------- struct MiniInja { enum class TokType { Text, Var }; struct Tok { TokType type; std::string payload; // Var: expression; Text: literal bool trim_left = false; bool trim_right = false; }; static std::vector compile(const std::string& src) { std::vector out; size_t i = 0, n = src.size(); while (i < n) { size_t open = src.find("{{", i); if (open == std::string::npos) { out.push_back({TokType::Text, src.substr(i), false, false}); break; } if (open > i) out.push_back({TokType::Text, src.substr(i, open - i), false, false}); bool trimL = (open + 2 < n && src[open+2] == '-'); size_t expr_start = open + 2 + (trimL ? 1 : 0); size_t close = src.find("}}", expr_start); if (close == std::string::npos) throw std::runtime_error("Unclosed {{ tag"); bool trimR = (close>0 && src[close-1]=='-'); size_t expr_end = close - (trimR ? 1 : 0); std::string expr = trim(src.substr(expr_start, expr_end - expr_start)); out.push_back({TokType::Var, expr, trimL, trimR}); i = close + 2; } return out; } static const json* resolve_ptr(const json& ctx, const std::string& path) { // supports a.b.c[2].d const json* cur = &ctx; size_t i = 0, n = path.size(); auto error = [&]{ throw std::runtime_error("Missing key in path: " + path); }; while (i < n) { if (path[i] == '[') { // index starts size_t r = path.find(']', i+1); if (r == std::string::npos) error(); int idx = std::stoi(path.substr(i+1, r-i-1)); if (!cur->is_array() || idx < 0 || (size_t)idx >= cur->size()) error(); cur = &(*cur)[(size_t)idx]; i = r + 1; if (icontains(key)) error(); cur = &(*cur)[key]; i = (dot==std::string::npos)? n : dot; while (iis_array() || idx < 0 || (size_t)idx >= cur->size()) error(); cur = &(*cur)[(size_t)idx]; i = r + 1; } if (i(); if (v.is_boolean()) return v.get() ? "true" : "false"; if (v.is_number_integer()) return std::to_string(v.get()); if (v.is_number_unsigned()) return std::to_string(v.get()); if (v.is_number_float()) return std::to_string(v.get()); return v.dump(); // arrays/objects } static std::string render(const std::vector& prog, const json& ctx) { std::string out; out.reserve(1024); for (size_t k = 0; k < prog.size(); ++k) { const auto& t = prog[k]; if (t.type == TokType::Text) { out.append(t.payload); } else { if (t.trim_left) { while (!out.empty() && (out.back()==' '||out.back()=='\t'||out.back()=='\r'||out.back()=='\n')) out.pop_back(); } const json* pv = resolve_ptr(ctx, t.payload); out.append(val_to_string(*pv)); if (t.trim_right && k+1 < prog.size() && prog[k+1].type == TokType::Text) { auto &nxt = const_cast(prog[k+1]); size_t j=0; while (j0) nxt.payload.erase(0, j); } } } return out; } }; // Per-thread template cache struct ThreadLocalTemplates { struct Entry { fs::file_time_type mtime; std::vector program; }; std::unordered_map cache; fs::path root; explicit ThreadLocalTemplates(fs::path root_) : root(std::move(root_)) {} const std::vector& get(const fs::path& full) { std::string key = full.string(); auto mtime = fs::last_write_time(full); auto it = cache.find(key); if (it != cache.end() && it->second.mtime == mtime) return it->second.program; std::ifstream f(full, std::ios::binary); if (!f) throw std::runtime_error("cannot open template"); std::string src((std::istreambuf_iterator(f)), std::istreambuf_iterator()); auto prog = MiniInja::compile(src); cache[key] = Entry{mtime, std::move(prog)}; return cache[key].program; } }; static thread_local std::unique_ptr tltpl; // ---- HTTP primitives --------------------------------------------------------- struct Request { std::string method, target, version; std::map headers; // lower-cased keys std::string body; bool keep_alive = true; }; struct Response { int status = 200; std::string reason = "OK"; std::vector> headers; std::string body; }; static std::string status_reason(int code){ switch(code){ case 200: return "OK"; case 400: return "Bad Request"; case 404: return "Not Found"; case 411: return "Length Required"; case 413: return "Payload Too Large"; case 415: return "Unsupported Media Type"; case 431: return "Request Header Fields Too Large"; case 500: return "Internal Server Error"; default: return "Error"; } } static void set_socket_timeouts(int fd){ timeval tv{.tv_sec = kRecvTimeoutSec, .tv_usec = 0}; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); tv = timeval{.tv_sec = kSendTimeoutSec, .tv_usec = 0}; setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); } static bool recv_append(int fd, std::string& buf){ char tmp[8192]; ssize_t n = ::recv(fd, tmp, sizeof(tmp), 0); if (n <= 0) return false; buf.append(tmp, (size_t)n); return true; } static bool send_all(int fd, const char* data, size_t len){ size_t off = 0; while (off < len) { ssize_t n = ::send(fd, data + off, len - off, 0); if (n < 0) { if (errno==EINTR) continue; return false; } off += (size_t)n; } return true; } static std::string build_response(const Response& res, bool keep_alive){ std::string out; out.reserve(128 + res.body.size() + 128); out += "HTTP/1.1 " + std::to_string(res.status) + " " + (res.reason.empty()?status_reason(res.status):res.reason) + "\r\n"; out += "Server: "; out += kServerName; out += "\r\n"; out += "Content-Length: " + std::to_string(res.body.size()) + "\r\n"; out += std::string("Connection: ") + (keep_alive ? "keep-alive" : "close") + "\r\n"; for (auto& h : res.headers) { out += h.first; out += ": "; out += h.second; out += "\r\n"; } out += "\r\n"; out += res.body; return out; } static bool is_json_ct(const std::map& h){ auto it = h.find("content-type"); if (it==h.end()) return false; auto v = to_lower(it->second); return v.find("application/json") != std::string::npos; } // Parse chunked body; returns static std::optional> parse_chunked_body(const std::string& buf, size_t start_off) { size_t p = start_off; std::string out; out.reserve(1024); while (true) { size_t eol = buf.find("\r\n", p); if (eol == std::string::npos) return std::nullopt; std::string size_line = buf.substr(p, eol - p); size_t sc = size_line.find(';'); if (sc != std::string::npos) size_line.resize(sc); size_line = trim(size_line); if (size_line.empty()) throw std::runtime_error("bad chunk size"); size_t chunk_size = 0; try { chunk_size = std::stoul(size_line, nullptr, 16); } catch (...) { throw std::runtime_error("bad chunk size"); } p = eol + 2; if (chunk_size == 0) { // last-chunk, then optional trailers terminated by CRLFCRLF size_t trailer_end = buf.find("\r\n\r\n", p); if (trailer_end == std::string::npos) return std::nullopt; size_t consumed = (trailer_end + 4) - start_off; return std::make_pair(consumed, std::move(out)); } if (buf.size() < p + chunk_size + 2) return std::nullopt; if (out.size() + chunk_size > kMaxBodyBytes) throw std::runtime_error("payload too large"); out.append(buf.data() + p, chunk_size); p += chunk_size; if (!(buf[p] == '\r' && buf[p+1] == '\n')) throw std::runtime_error("bad chunk CRLF"); p += 2; } } // Parse a full HTTP request if available in inbuf static std::optional parse_request(std::string& inbuf) { size_t hdr_end = inbuf.find("\r\n\r\n"); if (hdr_end == std::string::npos) return std::nullopt; if (hdr_end > kMaxHeaderBytes) throw std::runtime_error("headers too large"); std::string head = inbuf.substr(0, hdr_end); size_t line_end = head.find("\r\n"); if (line_end == std::string::npos) return std::nullopt; Request r; { // Request line std::string reqline = head.substr(0, line_end); size_t sp1 = reqline.find(' '); size_t sp2 = reqline.find(' ', sp1+1); if (sp1==std::string::npos||sp2==std::string::npos) throw std::runtime_error("bad request line"); r.method = reqline.substr(0, sp1); r.target = reqline.substr(sp1+1, sp2 - sp1 - 1); r.version = reqline.substr(sp2+1); } // Headers size_t off = line_end + 2; while (off < head.size()) { size_t nxt = head.find("\r\n", off); if (nxt == std::string::npos) break; std::string line = head.substr(off, nxt - off); off = nxt + 2; if (line.empty()) break; size_t colon = line.find(':'); if (colon == std::string::npos) continue; std::string k = to_lower(trim(line.substr(0, colon))); std::string v = trim(line.substr(colon+1)); r.headers[k] = v; } const size_t body_start = hdr_end + 4; const bool has_te = r.headers.count("transfer-encoding") && to_lower(r.headers.at("transfer-encoding")).find("chunked") != std::string::npos; if (has_te) { auto parsed = parse_chunked_body(inbuf, body_start); if (!parsed) return std::nullopt; // need more data const auto& [consumed_body, body] = *parsed; r.body = body; inbuf.erase(0, body_start + consumed_body); } else { size_t content_len = 0; if (r.headers.count("content-length")) { content_len = (size_t)std::stoull(r.headers["content-length"]); if (content_len > kMaxBodyBytes) throw std::runtime_error("payload too large"); } if (inbuf.size() < body_start + content_len) return std::nullopt; r.body.assign(inbuf.data() + body_start, content_len); inbuf.erase(0, body_start + content_len); } r.keep_alive = true; if (r.version == "HTTP/1.0") { r.keep_alive = (to_lower(r.headers["connection"]) == "keep-alive"); } else { if (r.headers.count("connection") && to_lower(r.headers["connection"]) == "close") r.keep_alive = false; } return r; } static bool send_100_continue_if_needed(int fd, const std::string& head_block){ // Search for "Expect: 100-continue" case-insensitive in headers block size_t off = 0; while (true) { size_t nxt = head_block.find("\r\n", off); if (nxt == std::string::npos) break; std::string line = head_block.substr(off, nxt - off); off = nxt + 2; if (line.empty()) break; size_t colon = line.find(':'); if (colon == std::string::npos) continue; std::string k = to_lower(trim(line.substr(0, colon))); if (k == "expect") { std::string v = to_lower(trim(line.substr(colon+1))); if (v.find("100-continue") != std::string::npos) { static const char kCont[] = "HTTP/1.1 100 Continue\r\n\r\n"; return send_all(fd, kCont, sizeof(kCont)-1); } } } return true; } // ---- Blocking queue & server ------------------------------------------------- template class BlockingQueue { std::mutex m; std::condition_variable cv; std::vector q; public: void push(T v){ {std::lock_guard lk(m); q.push_back(std::move(v)); } cv.notify_one(); } bool pop(T& out){ std::unique_lock lk(m); cv.wait(lk,[&]{ return !q.empty(); }); out = std::move(q.back()); q.pop_back(); return true; } }; struct Server { Settings cfg; int listen_fd = -1; std::atomic running{true}; BlockingQueue queue; std::vector workers; explicit Server(Settings s): cfg(std::move(s)) {} void start() { listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); std::exit(1); } int yes=1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); #ifdef SO_REUSEPORT setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof(yes)); #endif sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(cfg.port); if (cfg.address == "0.0.0.0") { addr.sin_addr.s_addr = htonl(INADDR_ANY); } else { in_addr a{}; if (::inet_pton(AF_INET, cfg.address.c_str(), &a) != 1) { std::cerr << "Invalid address\n"; std::exit(1); } addr.sin_addr = a; } if (::bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); std::exit(1); } if (::listen(listen_fd, kBacklog) < 0) { perror("listen"); std::exit(1); } // workers for (size_t i=0;i(cfg.template_root); while (true) { int fd; queue.pop(fd); handle_connection(fd); ::shutdown(fd, SHUT_WR); ::close(fd); } } void handle_connection(int fd) { std::string inbuf; inbuf.reserve(16*1024); bool keep = true; while (keep) { // Ensure we have headers; also guard header size growth while (true) { size_t pos = inbuf.find("\r\n\r\n"); if (pos != std::string::npos) { // Send 100-continue if asked if (!send_100_continue_if_needed(fd, inbuf.substr(0, pos))) return; break; } if (inbuf.size() > kMaxHeaderBytes) { send_json_error(fd, 431, "headers too large", false); return; } if (!recv_append(fd, inbuf)) return; // closed or timeout } std::optional reqOpt; try { reqOpt = parse_request(inbuf); } catch (const std::exception& e) { int code = 400; std::string what = e.what(); if (what == "payload too large") code = 413; else if (what == "length required") code = 411; else if (what == "headers too large") code = 431; send_json_error(fd, code, what, false); return; } if (!reqOpt) { // Need more body bytes if (!recv_append(fd, inbuf)) return; continue; } Request req = std::move(*reqOpt); keep = req.keep_alive; if (!(req.method=="POST" && req.target=="/render")) { send_json_error(fd, 404, "Not Found", keep); continue; } if (!is_json_ct(req.headers)) { send_json_error(fd, 415, "Content-Type must be application/json", keep); continue; } if (req.body.size() > kMaxBodyBytes) { send_json_error(fd, 413, "Body too large", keep); continue; } // Parse JSON request json jreq; try { jreq = json::parse(req.body); } catch (const std::exception& e) { send_json_error(fd, 400, std::string("Invalid JSON: ") + e.what(), keep); continue; } if (!jreq.contains("template") || !jreq["template"].is_string()) { send_json_error(fd, 400, R"(Missing "template" (string))", keep); continue; } if (jreq.contains("context") && !jreq["context"].is_object()) { send_json_error(fd, 400, R"("context" must be an object)", keep); continue; } std::string reason; fs::path full = secure_resolve_template(cfg.template_root, jreq["template"].get(), reason); if (full.empty()) { send_json_error(fd, 400, "Template error: " + reason, keep); continue; } json ctx = jreq.value("context", json::object()); // Render Response res; auto t0 = std::chrono::steady_clock::now(); try { const auto& prog = tltpl->get(full); std::string rendered = MiniInja::render(prog, ctx); auto t1 = std::chrono::steady_clock::now(); auto us = std::chrono::duration_cast(t1 - t0).count(); res.status = 200; res.reason = "OK"; res.headers.push_back({"Content-Type","text/plain; charset=utf-8"}); res.headers.push_back({"Cache-Control","no-store"}); res.headers.push_back({"X-Render-Time-Us", std::to_string(us)}); res.body = std::move(rendered); } catch (const std::exception& e) { send_json_error(fd, 400, std::string("Render error: ") + e.what(), keep); continue; } auto raw = build_response(res, keep); if (!send_all(fd, raw.data(), raw.size())) return; } } }; // ---- Args & main ------------------------------------------------------------- static Settings parse_args(int argc, char** argv){ Settings s; for (int i=1;istd::string{ if (i+1>=argc) throw std::runtime_error("Missing value for "+a); return std::string(argv[++i]); }; if (a=="--templates"||a=="-t") s.template_root = next(); else if (a=="--address"||a=="-a") s.address = next(); else if (a=="--port"||a=="-p") s.port = std::stoi(next()); else if (a=="--threads"||a=="-w") s.threads = (size_t)std::stoul(next()); else if (a=="--help"||a=="-h") { std::cout << "Usage: sinja --templates DIR [--address ADDR] [--port PORT] [--threads N]\n" " --templates, -t Template directory (required)\n" " --address, -a Bind address (default 0.0.0.0)\n" " --port, -p Port (default 8080)\n" " --threads, -w Worker threads (default HW concurrency)\n"; std::exit(0); } else throw std::runtime_error("Unknown arg: " + a); } if (s.template_root.empty()) throw std::runtime_error("Missing required --templates DIR"); if (!fs::exists(s.template_root) || !fs::is_directory(s.template_root)) throw std::runtime_error("Template directory invalid: " + s.template_root.string()); s.template_root = fs::weakly_canonical(s.template_root); return s; } static std::atomic g_shutdown{false}; static void on_signal(int){ g_shutdown.store(true); } int main(int argc, char** argv){ try { auto cfg = parse_args(argc, argv); std::signal(SIGPIPE, SIG_IGN); std::signal(SIGINT, on_signal); std::signal(SIGTERM, on_signal); std::cout << "Starting " << kServerName << "\n" << " Address : " << cfg.address << "\n" << " Port : " << cfg.port << "\n" << " Threads : " << cfg.threads << "\n" << " Templates : " << cfg.template_root << "\n"; Server srv(std::move(cfg)); // simple foreground run; Ctrl+C to stop srv.start(); } catch (const std::exception& e) { std::cerr << "Fatal: " << e.what() << "\n"; return 1; } return 0; }