atproto relay implementation in zig zlay.waow.tech

fix: URL-decode query parameters (cursor, did)

clients may percent-encode colons in DIDs (did%3Aplc%3A...), which
broke cursor-based pagination — the encoded cursor didn't match any
RocksDB key so the scan restarted from the beginning.

adds queryParamDecoded() with a hexVal helper for percent-decoding
into a caller-provided buffer. applied to cursor in
listReposByCollection and did in getRepoStatus/getLatestCommit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+56 -3
+56 -3
src/main.zig
··· 591 591 } 592 592 593 593 fn handleGetRepoStatus(stream: std.net.Stream, query: []const u8, persist: *event_log_mod.DiskPersist) void { 594 - const did = queryParam(query, "did") orelse { 594 + var did_buf: [256]u8 = undefined; 595 + const did = queryParamDecoded(query, "did", &did_buf) orelse { 595 596 httpRespondJson(stream, "400 Bad Request", "{\"error\":\"BadRequest\",\"message\":\"did parameter required\"}"); 596 597 return; 597 598 }; ··· 651 652 } 652 653 653 654 fn handleGetLatestCommit(stream: std.net.Stream, query: []const u8, persist: *event_log_mod.DiskPersist) void { 654 - const did = queryParam(query, "did") orelse { 655 + var did_buf: [256]u8 = undefined; 656 + const did = queryParamDecoded(query, "did", &did_buf) orelse { 655 657 httpRespondJson(stream, "400 Bad Request", "{\"error\":\"BadRequest\",\"message\":\"did parameter required\"}"); 656 658 return; 657 659 }; ··· 736 738 return; 737 739 } 738 740 739 - const cursor_did = queryParam(query, "cursor"); 741 + var cursor_buf: [256]u8 = undefined; 742 + const cursor_did = queryParamDecoded(query, "cursor", &cursor_buf); 740 743 741 744 // scan collection index 742 745 var did_buf: [65536]u8 = undefined; ··· 783 786 } 784 787 } 785 788 return null; 789 + } 790 + 791 + /// like queryParam but percent-decodes the value into buf. 792 + /// returns null if the param is missing, or a slice into buf with the decoded value. 793 + fn queryParamDecoded(query: []const u8, name: []const u8, buf: []u8) ?[]const u8 { 794 + const raw = queryParam(query, name) orelse return null; 795 + var i: usize = 0; 796 + var out: usize = 0; 797 + while (i < raw.len) { 798 + if (raw[i] == '%' and i + 2 < raw.len) { 799 + const hi = hexVal(raw[i + 1]) orelse { 800 + if (out >= buf.len) return null; 801 + buf[out] = raw[i]; 802 + out += 1; 803 + i += 1; 804 + continue; 805 + }; 806 + const lo = hexVal(raw[i + 2]) orelse { 807 + if (out >= buf.len) return null; 808 + buf[out] = raw[i]; 809 + out += 1; 810 + i += 1; 811 + continue; 812 + }; 813 + if (out >= buf.len) return null; 814 + buf[out] = (hi << 4) | lo; 815 + out += 1; 816 + i += 3; 817 + } else if (raw[i] == '+') { 818 + if (out >= buf.len) return null; 819 + buf[out] = ' '; 820 + out += 1; 821 + i += 1; 822 + } else { 823 + if (out >= buf.len) return null; 824 + buf[out] = raw[i]; 825 + out += 1; 826 + i += 1; 827 + } 828 + } 829 + return buf[0..out]; 830 + } 831 + 832 + fn hexVal(c: u8) ?u4 { 833 + return switch (c) { 834 + '0'...'9' => @intCast(c - '0'), 835 + 'a'...'f' => @intCast(c - 'a' + 10), 836 + 'A'...'F' => @intCast(c - 'A' + 10), 837 + else => null, 838 + }; 786 839 } 787 840 788 841 fn httpRespondJson(stream: std.net.Stream, status: []const u8, body: []const u8) void {