search for standard sites pub-search.waow.tech
search zig blog atproto
at multi-platform-schema 243 lines 8.3 kB view raw
1const std = @import("std"); 2const net = std.net; 3const http = std.http; 4const mem = std.mem; 5const activity = @import("activity.zig"); 6const search = @import("search.zig"); 7const stats = @import("stats.zig"); 8const dashboard = @import("dashboard.zig"); 9 10const HTTP_BUF_SIZE = 8192; 11const QUERY_PARAM_BUF_SIZE = 64; 12 13pub fn handleConnection(conn: net.Server.Connection) void { 14 defer conn.stream.close(); 15 16 var read_buffer: [HTTP_BUF_SIZE]u8 = undefined; 17 var write_buffer: [HTTP_BUF_SIZE]u8 = undefined; 18 19 var reader = conn.stream.reader(&read_buffer); 20 var writer = conn.stream.writer(&write_buffer); 21 22 var server = http.Server.init(reader.interface(), &writer.interface); 23 24 while (true) { 25 var request = server.receiveHead() catch |err| { 26 if (err != error.HttpConnectionClosing and err != error.EndOfStream) { 27 std.debug.print("http receive error: {}\n", .{err}); 28 } 29 return; 30 }; 31 handleRequest(&server, &request) catch |err| { 32 std.debug.print("request error: {}\n", .{err}); 33 return; 34 }; 35 if (!request.head.keep_alive) return; 36 } 37} 38 39fn handleRequest(server: *http.Server, request: *http.Server.Request) !void { 40 _ = server; 41 const target = request.head.target; 42 43 // cors preflight 44 if (request.head.method == .OPTIONS) { 45 try sendCorsHeaders(request, ""); 46 return; 47 } 48 49 if (mem.startsWith(u8, target, "/search")) { 50 try handleSearch(request, target); 51 } else if (mem.eql(u8, target, "/tags")) { 52 try handleTags(request); 53 } else if (mem.eql(u8, target, "/stats")) { 54 try handleStats(request); 55 } else if (mem.eql(u8, target, "/health")) { 56 try sendJson(request, "{\"status\":\"ok\"}"); 57 } else if (mem.eql(u8, target, "/popular")) { 58 try handlePopular(request); 59 } else if (mem.eql(u8, target, "/dashboard")) { 60 try handleDashboard(request); 61 } else if (mem.eql(u8, target, "/api/dashboard")) { 62 try handleDashboardApi(request); 63 } else if (mem.startsWith(u8, target, "/similar")) { 64 try handleSimilar(request, target); 65 } else if (mem.eql(u8, target, "/activity")) { 66 try handleActivity(request); 67 } else { 68 try sendNotFound(request); 69 } 70} 71 72fn handleSearch(request: *http.Server.Request, target: []const u8) !void { 73 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 74 defer arena.deinit(); 75 const alloc = arena.allocator(); 76 77 // parse query param: /search?q=something&tag=foo 78 const query = parseQueryParam(alloc, target, "q") catch ""; 79 const tag_filter = parseQueryParam(alloc, target, "tag") catch null; 80 81 if (query.len == 0 and tag_filter == null) { 82 try sendJson(request, "{\"error\":\"enter a search term\"}"); 83 return; 84 } 85 86 // perform FTS search - arena handles cleanup 87 const results = search.search(alloc, query, tag_filter) catch |err| { 88 stats.recordError(); 89 return err; 90 }; 91 stats.recordSearch(query); 92 try sendJson(request, results); 93} 94 95fn handleTags(request: *http.Server.Request) !void { 96 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 97 defer arena.deinit(); 98 const alloc = arena.allocator(); 99 100 const tags = try stats.getTags(alloc); 101 try sendJson(request, tags); 102} 103 104fn handlePopular(request: *http.Server.Request) !void { 105 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 106 defer arena.deinit(); 107 const alloc = arena.allocator(); 108 109 const popular = try stats.getPopular(alloc, 5); 110 try sendJson(request, popular); 111} 112 113fn parseQueryParam(alloc: std.mem.Allocator, target: []const u8, param: []const u8) ![]const u8 { 114 // look for ?param= or &param= 115 const patterns = [_][]const u8{ "?", "&" }; 116 for (patterns) |prefix| { 117 var search_buf: [QUERY_PARAM_BUF_SIZE]u8 = undefined; 118 const search_str = std.fmt.bufPrint(&search_buf, "{s}{s}=", .{ prefix, param }) catch continue; 119 if (mem.indexOf(u8, target, search_str)) |idx| { 120 const encoded = target[idx + search_str.len ..]; 121 const end = mem.indexOf(u8, encoded, "&") orelse encoded.len; 122 const query_encoded = encoded[0..end]; 123 const buf = try alloc.dupe(u8, query_encoded); 124 // decode + as space (form-urlencoded), then percent-decode 125 for (buf) |*c| { 126 if (c.* == '+') c.* = ' '; 127 } 128 return std.Uri.percentDecodeInPlace(buf); 129 } 130 } 131 return error.NotFound; 132} 133 134fn handleStats(request: *http.Server.Request) !void { 135 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 136 defer arena.deinit(); 137 const alloc = arena.allocator(); 138 139 const db_stats = stats.getStats(); 140 141 var response: std.ArrayList(u8) = .{}; 142 defer response.deinit(alloc); 143 144 try response.print(alloc, "{{\"documents\":{d},\"publications\":{d},\"cache_hits\":{d},\"cache_misses\":{d}}}", .{ db_stats.documents, db_stats.publications, db_stats.cache_hits, db_stats.cache_misses }); 145 146 try sendJson(request, response.items); 147} 148 149fn sendJson(request: *http.Server.Request, body: []const u8) !void { 150 try request.respond(body, .{ 151 .status = .ok, 152 .extra_headers = &.{ 153 .{ .name = "content-type", .value = "application/json" }, 154 .{ .name = "access-control-allow-origin", .value = "*" }, 155 .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 156 .{ .name = "access-control-allow-headers", .value = "content-type" }, 157 }, 158 }); 159} 160 161fn sendCorsHeaders(request: *http.Server.Request, body: []const u8) !void { 162 try request.respond(body, .{ 163 .status = .no_content, 164 .extra_headers = &.{ 165 .{ .name = "access-control-allow-origin", .value = "*" }, 166 .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 167 .{ .name = "access-control-allow-headers", .value = "content-type" }, 168 }, 169 }); 170} 171 172fn sendNotFound(request: *http.Server.Request) !void { 173 try request.respond("{\"error\":\"not found\"}", .{ 174 .status = .not_found, 175 .extra_headers = &.{ 176 .{ .name = "content-type", .value = "application/json" }, 177 .{ .name = "access-control-allow-origin", .value = "*" }, 178 }, 179 }); 180} 181 182fn handleDashboardApi(request: *http.Server.Request) !void { 183 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 184 defer arena.deinit(); 185 const alloc = arena.allocator(); 186 187 const data = dashboard.fetch(alloc) catch { 188 try sendJson(request, "{\"error\":\"failed to fetch dashboard data\"}"); 189 return; 190 }; 191 192 const json_response = dashboard.toJson(alloc, data) catch { 193 try sendJson(request, "{\"error\":\"failed to serialize dashboard data\"}"); 194 return; 195 }; 196 197 try sendJson(request, json_response); 198} 199 200fn handleDashboard(request: *http.Server.Request) !void { 201 try request.respond("", .{ 202 .status = .moved_permanently, 203 .extra_headers = &.{ 204 .{ .name = "location", .value = "https://leaflet-search.pages.dev/dashboard.html" }, 205 }, 206 }); 207} 208 209fn handleSimilar(request: *http.Server.Request, target: []const u8) !void { 210 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 211 defer arena.deinit(); 212 const alloc = arena.allocator(); 213 214 const uri = parseQueryParam(alloc, target, "uri") catch { 215 try sendJson(request, "{\"error\":\"missing uri parameter\"}"); 216 return; 217 }; 218 219 const results = search.findSimilar(alloc, uri, 5) catch { 220 try sendJson(request, "[]"); 221 return; 222 }; 223 224 try sendJson(request, results); 225} 226 227fn handleActivity(request: *http.Server.Request) !void { 228 const counts = activity.getCounts(); 229 230 // format as JSON array 231 var buf: [512]u8 = undefined; 232 var stream = std.io.fixedBufferStream(&buf); 233 const writer = stream.writer(); 234 235 writer.writeByte('[') catch return; 236 for (counts, 0..) |c, i| { 237 if (i > 0) writer.writeByte(',') catch return; 238 writer.print("{d}", .{c}) catch return; 239 } 240 writer.writeByte(']') catch return; 241 242 try sendJson(request, stream.getWritten()); 243}