polls on atproto pollz.waow.tech
atproto zig
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

migrate backend to zig 0.16, frontend improvements

backend: zig 0.15 → 0.16 (zat v0.3.0-alpha.7, zqlite master)
- Io.Threaded with explicit io passing (no debug_io hack)
- Io.Mutex, Io.Timestamp, Io.net, smp_allocator
- thread-per-connection replaces Thread.Pool
- Dockerfile updated to zig 0.16.0-dev.3059

frontend: handle autocomplete, layout/styling updates

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

+824 -205
+1
.gitignore
··· 5 5 .svelte-kit 6 6 .zig-cache 7 7 zig-out 8 + zig-pkg 8 9 *.db
+3 -3
backend/Dockerfile
··· 7 7 xz-utils \ 8 8 && rm -rf /var/lib/apt/lists/* 9 9 10 - # install zig 0.15.2 11 - RUN curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C /usr/local \ 12 - && ln -s /usr/local/zig-x86_64-linux-0.15.2/zig /usr/local/bin/zig 10 + # install zig 0.16 11 + RUN curl -L https://ziglang.org/builds/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d.tar.xz | tar -xJ -C /usr/local \ 12 + && ln -s /usr/local/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d/zig /usr/local/bin/zig 13 13 14 14 WORKDIR /app 15 15 COPY build.zig build.zig.zon ./
+5 -5
backend/build.zig.zon
··· 2 2 .name = .pollz_backend, 3 3 .version = "0.0.1", 4 4 .fingerprint = 0x855197507943a911, 5 - .minimum_zig_version = "0.15.0", 5 + .minimum_zig_version = "0.16.0", 6 6 .dependencies = .{ 7 7 .zat = .{ 8 - .url = "https://tangled.org/zat.dev/zat/archive/v0.2.17.tar.gz", 9 - .hash = "zat-0.2.17-5PuC7sReBQCpSZJrd5yztuBJSgENKaE-vENalyKrWATk", 8 + .url = "https://tangled.org/zat.dev/zat/archive/v0.3.0-alpha.7.tar.gz", 9 + .hash = "zat-0.3.0-alpha.7-5PuC7uNjBQDv28db31DEKkFn1tU5I4f1GfJs-RrG8_pS", 10 10 }, 11 11 .zqlite = .{ 12 - .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#e041f81c6b11b7381b6358030d57ca95dcd54d30", 13 - .hash = "zqlite-0.0.0-RWLaYzS6mAAAzVSs8HPbmwl4DqH5kXG0Ob87asf1YNGL", 12 + .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#05a88d6758753e1c63fdd45b211dde2057094b0c", 13 + .hash = "zqlite-0.0.1-RWLaYz6bmAAT7E_jxopXf-j5Ea8VQldnxsd6TU8sa0Bb", 14 14 }, 15 15 }, 16 16 .paths = .{
+74 -45
backend/src/db.zig
··· 1 1 const std = @import("std"); 2 2 const json = std.json; 3 3 const mem = std.mem; 4 - const Thread = std.Thread; 5 4 const Allocator = mem.Allocator; 5 + const Io = std.Io; 6 6 const zqlite = @import("zqlite"); 7 + 8 + // module state — initialized via init(), not from a global 9 + var io: Io = undefined; 10 + 11 + fn timestamp() i64 { 12 + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); 13 + } 7 14 8 15 pub var conn: zqlite.Conn = undefined; 9 - pub var mutex: Thread.Mutex = .{}; 16 + pub var mutex: Io.Mutex = .init; 10 17 11 - pub fn init(path: [*:0]const u8) !void { 18 + pub fn init(app_io: Io, path: [*:0]const u8) !void { 19 + io = app_io; 12 20 std.debug.print("opening database at: {s}\n", .{path}); 13 21 conn = zqlite.open(path, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite) catch |err| { 14 22 std.debug.print("failed to open database: {}\n", .{err}); ··· 22 30 } 23 31 24 32 fn initSchema() !void { 25 - mutex.lock(); 26 - defer mutex.unlock(); 33 + mutex.lockUncancelable(io); 34 + defer mutex.unlock(io); 27 35 28 36 conn.execNoArgs( 29 37 \\CREATE TABLE IF NOT EXISTS polls ( ··· 135 143 } 136 144 137 145 pub fn insertPoll(uri: []const u8, did: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8) !void { 138 - mutex.lock(); 139 - defer mutex.unlock(); 146 + mutex.lockUncancelable(io); 147 + defer mutex.unlock(io); 140 148 141 149 conn.exec( 142 150 "INSERT OR IGNORE INTO polls (uri, repo, rkey, text, options, created_at) VALUES (?, ?, ?, ?, ?, ?)", ··· 148 156 } 149 157 150 158 pub fn insertVote(uri: []const u8, subject: []const u8, option: i32, voter: []const u8, created_at: ?[]const u8) !void { 151 - mutex.lock(); 152 - defer mutex.unlock(); 159 + mutex.lockUncancelable(io); 160 + defer mutex.unlock(io); 153 161 154 162 // upsert: update if exists and new vote is newer, otherwise insert 155 163 // this handles out-of-order events from tap ··· 168 176 } 169 177 170 178 pub fn deletePoll(uri: []const u8) void { 171 - mutex.lock(); 172 - defer mutex.unlock(); 179 + mutex.lockUncancelable(io); 180 + defer mutex.unlock(io); 173 181 174 182 conn.exec("DELETE FROM polls WHERE uri = ?", .{uri}) catch |err| { 175 183 std.debug.print("db delete poll error: {}\n", .{err}); ··· 181 189 } 182 190 183 191 pub fn deleteVote(uri: []const u8) void { 184 - mutex.lock(); 185 - defer mutex.unlock(); 192 + mutex.lockUncancelable(io); 193 + defer mutex.unlock(io); 186 194 187 195 // only delete if the URI matches - if a newer vote replaced this one, 188 196 // the URI won't match and we should not delete ··· 191 199 }; 192 200 } 193 201 202 + pub const VoteRecord = struct { 203 + uri: []const u8, 204 + option: i32, 205 + }; 206 + 207 + pub fn getVoteByVoter(subject: []const u8, voter: []const u8) ?VoteRecord { 208 + mutex.lockUncancelable(io); 209 + defer mutex.unlock(io); 210 + 211 + const row = conn.row( 212 + "SELECT uri, option FROM votes WHERE subject = ? AND voter = ?", 213 + .{ subject, voter }, 214 + ) catch return null; 215 + if (row == null) return null; 216 + 217 + return .{ 218 + .uri = row.?.text(0), 219 + .option = @intCast(row.?.int(1)), 220 + }; 221 + } 222 + 194 223 // --- OAuth --- 195 224 196 225 pub fn insertAuthRequest( ··· 204 233 dpop_nonce: []const u8, 205 234 dpop_private_key: []const u8, 206 235 ) !void { 207 - mutex.lock(); 208 - defer mutex.unlock(); 236 + mutex.lockUncancelable(io); 237 + defer mutex.unlock(io); 209 238 210 239 conn.exec( 211 240 \\INSERT INTO oauth_auth_request ··· 221 250 scope, 222 251 dpop_nonce, 223 252 dpop_private_key, 224 - @as(i64, @intCast(std.time.timestamp())), 253 + timestamp(), 225 254 }) catch |err| { 226 255 std.debug.print("db insert auth request error: {}\n", .{err}); 227 256 return err; ··· 241 270 }; 242 271 243 272 pub fn getAuthRequest(state: []const u8) ?AuthRequest { 244 - mutex.lock(); 245 - defer mutex.unlock(); 273 + mutex.lockUncancelable(io); 274 + defer mutex.unlock(io); 246 275 247 276 const row = conn.row( 248 277 "SELECT state, authserver_iss, did, handle, pds_url, pkce_verifier, scope, dpop_authserver_nonce, dpop_private_key FROM oauth_auth_request WHERE state = ?", ··· 264 293 } 265 294 266 295 pub fn deleteAuthRequest(state: []const u8) void { 267 - mutex.lock(); 268 - defer mutex.unlock(); 296 + mutex.lockUncancelable(io); 297 + defer mutex.unlock(io); 269 298 270 299 conn.exec("DELETE FROM oauth_auth_request WHERE state = ?", .{state}) catch |err| { 271 300 std.debug.print("db delete auth request error: {}\n", .{err}); ··· 283 312 dpop_pds_nonce: []const u8, 284 313 dpop_private_key: []const u8, 285 314 ) !void { 286 - mutex.lock(); 287 - defer mutex.unlock(); 315 + mutex.lockUncancelable(io); 316 + defer mutex.unlock(io); 288 317 289 318 conn.exec( 290 319 \\INSERT INTO oauth_session ··· 311 340 dpop_authserver_nonce, 312 341 dpop_pds_nonce, 313 342 dpop_private_key, 314 - @as(i64, @intCast(std.time.timestamp())), 343 + timestamp(), 315 344 }) catch |err| { 316 345 std.debug.print("db upsert session error: {}\n", .{err}); 317 346 return err; ··· 331 360 }; 332 361 333 362 pub fn getSession(did: []const u8) ?Session { 334 - mutex.lock(); 335 - defer mutex.unlock(); 363 + mutex.lockUncancelable(io); 364 + defer mutex.unlock(io); 336 365 337 366 const row = conn.row( 338 367 \\SELECT did, handle, pds_url, authserver_iss, access_token, refresh_token, ··· 355 384 } 356 385 357 386 pub fn deleteSession(did: []const u8) void { 358 - mutex.lock(); 359 - defer mutex.unlock(); 387 + mutex.lockUncancelable(io); 388 + defer mutex.unlock(io); 360 389 361 390 conn.exec("DELETE FROM oauth_session WHERE did = ?", .{did}) catch |err| { 362 391 std.debug.print("db delete session error: {}\n", .{err}); ··· 364 393 } 365 394 366 395 pub fn updateSessionNonce(did: []const u8, field: enum { authserver, pds }, nonce: []const u8) void { 367 - mutex.lock(); 368 - defer mutex.unlock(); 396 + mutex.lockUncancelable(io); 397 + defer mutex.unlock(io); 369 398 370 399 switch (field) { 371 400 .authserver => conn.exec("UPDATE oauth_session SET dpop_authserver_nonce = ? WHERE did = ?", .{ nonce, did }) catch {}, ··· 374 403 } 375 404 376 405 pub fn updateSessionTokens(did: []const u8, access_token: []const u8, refresh_token: []const u8) void { 377 - mutex.lock(); 378 - defer mutex.unlock(); 406 + mutex.lockUncancelable(io); 407 + defer mutex.unlock(io); 379 408 380 409 conn.exec("UPDATE oauth_session SET access_token = ?, refresh_token = ? WHERE did = ?", .{ access_token, refresh_token, did }) catch {}; 381 410 } ··· 383 412 // --- Exchange tokens (short-lived, one-time use) --- 384 413 385 414 pub fn insertExchangeToken(token: []const u8, did: []const u8) !void { 386 - mutex.lock(); 387 - defer mutex.unlock(); 415 + mutex.lockUncancelable(io); 416 + defer mutex.unlock(io); 388 417 389 418 conn.exec( 390 419 "INSERT INTO exchange_tokens (token, did, created_at) VALUES (?, ?, ?)", 391 - .{ token, did, @as(i64, @intCast(std.time.timestamp())) }, 420 + .{ token, did, timestamp() }, 392 421 ) catch |err| { 393 422 std.debug.print("db insert exchange token error: {}\n", .{err}); 394 423 return err; ··· 396 425 } 397 426 398 427 pub fn consumeExchangeToken(token: []const u8) ?[]const u8 { 399 - mutex.lock(); 400 - defer mutex.unlock(); 428 + mutex.lockUncancelable(io); 429 + defer mutex.unlock(io); 401 430 402 - const cutoff = @as(i64, @intCast(std.time.timestamp())) - 60; // 60 second expiry 431 + const cutoff = timestamp() - 60; // 60 second expiry 403 432 const row = conn.row( 404 433 "SELECT did FROM exchange_tokens WHERE token = ? AND created_at > ?", 405 434 .{ token, cutoff }, ··· 413 442 } 414 443 415 444 pub fn cleanupExpiredAuthRequests() void { 416 - mutex.lock(); 417 - defer mutex.unlock(); 445 + mutex.lockUncancelable(io); 446 + defer mutex.unlock(io); 418 447 419 448 // delete auth requests older than 10 minutes 420 - const cutoff = @as(i64, @intCast(std.time.timestamp())) - 600; 449 + const cutoff = timestamp() - 600; 421 450 conn.exec("DELETE FROM oauth_auth_request WHERE created_at < ?", .{cutoff}) catch {}; 422 451 } 423 452 ··· 431 460 }; 432 461 433 462 pub fn getProfile(did: []const u8) ?Profile { 434 - mutex.lock(); 435 - defer mutex.unlock(); 463 + mutex.lockUncancelable(io); 464 + defer mutex.unlock(io); 436 465 437 466 const row = conn.row( 438 467 "SELECT did, handle, avatar_url, fetched_at FROM profiles WHERE did = ?", ··· 449 478 } 450 479 451 480 pub fn upsertProfile(did: []const u8, handle: []const u8, avatar_url: []const u8) void { 452 - mutex.lock(); 453 - defer mutex.unlock(); 481 + mutex.lockUncancelable(io); 482 + defer mutex.unlock(io); 454 483 455 484 conn.exec( 456 485 \\INSERT INTO profiles (did, handle, avatar_url, fetched_at) ··· 463 492 did, 464 493 handle, 465 494 avatar_url, 466 - @as(i64, @intCast(std.time.timestamp())), 495 + timestamp(), 467 496 }) catch |err| { 468 497 std.debug.print("db upsert profile error: {}\n", .{err}); 469 498 };
+301 -78
backend/src/http.zig
··· 1 1 const std = @import("std"); 2 - const net = std.net; 2 + const Io = std.Io; 3 + const net = Io.net; 3 4 const http = std.http; 4 5 const mem = std.mem; 5 6 const json = std.json; 6 - const crypto = std.crypto; 7 7 const db = @import("db.zig"); 8 8 const oauth = zat.oauth; 9 9 const zat = @import("zat"); 10 10 11 + // module state — initialized via init(), not from a global 12 + var io: Io = undefined; 13 + 14 + pub fn init(app_io: Io) void { 15 + io = app_io; 16 + } 17 + 11 18 const SCOPE = "atproto repo:tech.waow.pollz.poll repo:tech.waow.pollz.vote"; 12 19 20 + fn getenv(name: [*:0]const u8) ?[]const u8 { 21 + return if (std.c.getenv(name)) |p| std.mem.span(p) else null; 22 + } 23 + 24 + fn timestamp() i64 { 25 + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); 26 + } 27 + 13 28 fn getClientId() []const u8 { 14 - return std.posix.getenv("OAUTH_CLIENT_ID") orelse "https://api.pollz.waow.tech/oauth-client-metadata.json"; 29 + return getenv("OAUTH_CLIENT_ID") orelse "https://api.pollz.waow.tech/oauth-client-metadata.json"; 15 30 } 16 31 17 32 fn getRedirectUri() []const u8 { 18 - return std.posix.getenv("OAUTH_REDIRECT_URI") orelse "https://api.pollz.waow.tech/oauth/callback"; 33 + return getenv("OAUTH_REDIRECT_URI") orelse "https://api.pollz.waow.tech/oauth/callback"; 19 34 } 20 35 21 36 fn getFrontendOrigin() []const u8 { 22 - return std.posix.getenv("FRONTEND_ORIGIN") orelse "https://pollz.waow.tech"; 37 + return getenv("FRONTEND_ORIGIN") orelse "https://pollz.waow.tech"; 23 38 } 24 39 25 40 fn getClientOrigin() []const u8 { ··· 32 47 } 33 48 34 49 fn getClientKeypair() !zat.Keypair { 35 - const key_hex = std.posix.getenv("OAUTH_CLIENT_SECRET_KEY") orelse return error.MissingClientKey; 50 + const key_hex = getenv("OAUTH_CLIENT_SECRET_KEY") orelse return error.MissingClientKey; 36 51 if (key_hex.len != 64) return error.InvalidClientKey; 37 52 var key_bytes: [32]u8 = undefined; 38 53 _ = std.fmt.hexToBytes(&key_bytes, key_hex) catch return error.InvalidClientKey; 39 54 return zat.Keypair.fromSecretKey(.p256, key_bytes); 40 55 } 41 56 42 - pub fn handleConnection(conn_: net.Server.Connection) void { 43 - defer conn_.stream.close(); 57 + pub fn handleConnection(stream: net.Stream) void { 58 + defer stream.close(io); 44 59 45 60 var read_buffer: [8192]u8 = undefined; 46 61 var write_buffer: [8192]u8 = undefined; 47 62 48 - var reader = conn_.stream.reader(&read_buffer); 49 - var writer = conn_.stream.writer(&write_buffer); 63 + var reader = net.Stream.Reader.init(stream, io, &read_buffer); 64 + var writer = net.Stream.Writer.init(stream, io, &write_buffer); 50 65 51 - var server = http.Server.init(reader.interface(), &writer.interface); 66 + var server = http.Server.init(&reader.interface, &writer.interface); 52 67 53 68 while (true) { 54 69 var request = server.receiveHead() catch |err| { ··· 109 124 try sendNotFound(request); 110 125 } 111 126 } else if (request.head.method == .DELETE) { 112 - if (mem.startsWith(u8, target, "/api/polls/")) { 127 + if (mem.endsWith(u8, target, "/vote")) { 128 + try handleDeleteVote(request); 129 + } else if (mem.startsWith(u8, target, "/api/polls/")) { 113 130 const uri_encoded = target["/api/polls/".len..]; 114 131 try handleDeletePoll(request, uri_encoded); 115 132 } else { ··· 147 164 // --- OAuth endpoints --- 148 165 149 166 fn handleClientMetadata(request: *http.Server.Request) !void { 150 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 167 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 151 168 defer arena.deinit(); 152 169 const alloc = arena.allocator(); 153 170 ··· 164 181 return; 165 182 }; 166 183 167 - var body: std.ArrayList(u8) = .{}; 184 + var body: std.ArrayList(u8) = .empty; 168 185 defer body.deinit(alloc); 169 186 170 187 try body.print(alloc, ··· 188 205 } 189 206 190 207 fn handleJwks(request: *http.Server.Request) !void { 191 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 208 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 192 209 defer arena.deinit(); 193 210 const alloc = arena.allocator(); 194 211 ··· 206 223 } 207 224 208 225 fn handleLogin(request: *http.Server.Request) !void { 209 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 226 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 210 227 defer arena.deinit(); 211 228 const alloc = arena.allocator(); 212 229 ··· 218 235 }; 219 236 220 237 // resolve handle → DID → PDS → auth server 221 - var handle_resolver = zat.HandleResolver.init(alloc); 238 + var handle_resolver = zat.HandleResolver.init(io, alloc); 222 239 defer handle_resolver.deinit(); 223 240 224 241 const did = handle_resolver.resolve(zat.Handle.parse(handle_str) orelse { ··· 230 247 return; 231 248 }; 232 249 233 - var did_resolver = zat.DidResolver.init(alloc); 250 + var did_resolver = zat.DidResolver.init(io, alloc); 234 251 defer did_resolver.deinit(); 235 252 236 253 var did_doc = did_resolver.resolve(zat.Did.parse(did) orelse { ··· 277 294 }; 278 295 279 296 // generate PKCE + state + per-session DPoP key 280 - const pkce_verifier = try oauth.generatePkceVerifier(alloc); 297 + const pkce_verifier = try oauth.generatePkceVerifier(alloc, io); 281 298 const pkce_challenge = try oauth.generatePkceChallenge(alloc, pkce_verifier); 282 - const state = try oauth.generateState(alloc); 299 + const state = try oauth.generateState(alloc, io); 283 300 284 301 // generate a per-session DPoP keypair (separate from client secret key) 285 302 var dpop_key_bytes: [32]u8 = undefined; 286 - crypto.random.bytes(&dpop_key_bytes); 303 + io.random(&dpop_key_bytes); 287 304 const dpop_keypair = zat.Keypair.fromSecretKey(.p256, dpop_key_bytes) catch { 288 305 // extremely unlikely — retry with new random bytes 289 306 try sendError(request, .internal_server_error, "key generation failed"); ··· 335 352 }; 336 353 337 354 // redirect to auth server 338 - var redirect_url: std.ArrayList(u8) = .{}; 355 + var redirect_url: std.ArrayList(u8) = .empty; 339 356 defer redirect_url.deinit(alloc); 340 357 try redirect_url.print( 341 358 alloc, ··· 347 364 } 348 365 349 366 fn handleCallback(request: *http.Server.Request) !void { 350 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 367 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 351 368 defer arena.deinit(); 352 369 const alloc = arena.allocator(); 353 370 ··· 434 451 } 435 452 436 453 // store session 454 + std.debug.print("callback: token exchange succeeded for {s}, storing session\n", .{auth_req.did}); 437 455 db.upsertSession( 438 456 auth_req.did, 439 457 auth_req.handle, ··· 453 471 db.deleteAuthRequest(state); 454 472 455 473 // generate a short-lived exchange token and redirect to frontend 456 - // (setting cookies on 302 redirects from cross-site OAuth flows is unreliable) 457 474 var token_bytes: [16]u8 = undefined; 458 - crypto.random.bytes(&token_bytes); 475 + io.random(&token_bytes); 459 476 const exchange_token = std.fmt.bytesToHex(token_bytes, .lower); 460 477 461 478 db.insertExchangeToken(&exchange_token, auth_req.did) catch { ··· 463 480 return; 464 481 }; 465 482 466 - var redirect_url: std.ArrayList(u8) = .{}; 483 + var redirect_url: std.ArrayList(u8) = .empty; 467 484 defer redirect_url.deinit(alloc); 468 485 try redirect_url.print(alloc, "{s}/auth/callback?exchange_token={s}", .{ getFrontendOrigin(), &exchange_token }); 469 486 470 - try sendRedirect(request, redirect_url.items); 487 + // set cookie on the redirect itself (top-level same-site navigation is reliable) 488 + // the exchange endpoint also sets it, but fetch-based Set-Cookie can be unreliable 489 + var cookie_buf: [512]u8 = undefined; 490 + const cookie = std.fmt.bufPrint( 491 + &cookie_buf, 492 + "pollz_session={s}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000", 493 + .{auth_req.did}, 494 + ) catch { 495 + try sendError(request, .internal_server_error, "cookie error"); 496 + return; 497 + }; 498 + 499 + try request.respond("", .{ 500 + .status = .found, 501 + .extra_headers = &.{ 502 + .{ .name = "location", .value = redirect_url.items }, 503 + .{ .name = "set-cookie", .value = cookie }, 504 + }, 505 + }); 471 506 } 472 507 473 508 fn handleMe(request: *http.Server.Request) !void { ··· 476 511 return; 477 512 }; 478 513 514 + std.debug.print("handleMe: checking session for {s}\n", .{session_did}); 515 + 479 516 const session = db.getSession(session_did) orelse { 517 + std.debug.print("handleMe: session not found for {s}\n", .{session_did}); 480 518 try sendError(request, .unauthorized, "session not found"); 481 519 return; 482 520 }; 483 521 484 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 522 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 485 523 defer arena.deinit(); 486 524 const alloc = arena.allocator(); 487 525 488 - var body: std.ArrayList(u8) = .{}; 526 + // validate session is still usable by making a lightweight PDS request 527 + _ = pdsAuthedRequest(alloc, session, "GET", "/xrpc/com.atproto.server.getSession", null) catch |err| { 528 + std.debug.print("handleMe: PDS validation failed for {s}: {}\n", .{ session_did, err }); 529 + db.deleteSession(session_did); 530 + try sendError(request, .unauthorized, "session expired"); 531 + return; 532 + }; 533 + 534 + std.debug.print("handleMe: session valid for {s}\n", .{session_did}); 535 + 536 + var body: std.ArrayList(u8) = .empty; 489 537 defer body.deinit(alloc); 490 538 491 539 try body.print(alloc, ··· 496 544 } 497 545 498 546 fn handleExchange(request: *http.Server.Request) !void { 499 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 547 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 500 548 defer arena.deinit(); 501 549 const alloc = arena.allocator(); 502 550 ··· 517 565 }; 518 566 519 567 const did = db.consumeExchangeToken(token) orelse { 568 + std.debug.print("exchange: invalid or expired token\n", .{}); 520 569 try sendError(request, .unauthorized, "invalid or expired exchange token"); 521 570 return; 522 571 }; 523 572 573 + std.debug.print("exchange: token consumed for {s}\n", .{did}); 574 + 524 575 const session = db.getSession(did) orelse { 576 + std.debug.print("exchange: session not found for {s}\n", .{did}); 525 577 try sendError(request, .unauthorized, "session not found"); 526 578 return; 527 579 }; ··· 536 588 return; 537 589 }; 538 590 539 - var resp_body: std.ArrayList(u8) = .{}; 591 + var resp_body: std.ArrayList(u8) = .empty; 540 592 defer resp_body.deinit(alloc); 541 593 542 594 try resp_body.print(alloc, ··· 574 626 // --- BFF proxy endpoints --- 575 627 576 628 fn handleCreatePoll(request: *http.Server.Request) !void { 577 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 629 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 578 630 defer arena.deinit(); 579 631 const alloc = arena.allocator(); 580 632 ··· 618 670 // build record for PDS 619 671 const now = try formatTimestamp(alloc); 620 672 621 - var record: std.ArrayList(u8) = .{}; 673 + var record: std.ArrayList(u8) = .empty; 622 674 defer record.deinit(alloc); 623 675 624 676 try record.print(alloc, 625 677 \\{{"$type":"tech.waow.pollz.poll","text":"{s}","options":{f},"createdAt":"{s}"}} 626 678 , .{ text, json.fmt(options_val, .{}), now }); 627 679 628 - var xrpc_body: std.ArrayList(u8) = .{}; 680 + var xrpc_body: std.ArrayList(u8) = .empty; 629 681 defer xrpc_body.deinit(alloc); 630 682 631 683 try xrpc_body.print(alloc, ··· 633 685 , .{ session.did, record.items }); 634 686 635 687 // proxy to PDS 688 + std.debug.print("createPoll: calling PDS for {s}\n", .{session.did}); 636 689 const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch |err| { 690 + std.debug.print("createPoll: PDS request failed for {s}: {}\n", .{ session.did, err }); 637 691 if (err == error.Unauthorized) { 638 692 db.deleteSession(session_did); 639 693 try sendError(request, .unauthorized, "session expired"); ··· 643 697 return; 644 698 }; 645 699 700 + std.debug.print("createPoll: success for {s}\n", .{session.did}); 646 701 try sendJson(request, result); 647 702 } 648 703 649 704 fn handleVote(request: *http.Server.Request) !void { 650 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 705 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 651 706 defer arena.deinit(); 652 707 const alloc = arena.allocator(); 653 708 ··· 685 740 686 741 const now = try formatTimestamp(alloc); 687 742 688 - var record: std.ArrayList(u8) = .{}; 743 + var record: std.ArrayList(u8) = .empty; 689 744 defer record.deinit(alloc); 690 745 691 746 try record.print(alloc, 692 747 \\{{"$type":"tech.waow.pollz.vote","subject":"{s}","option":{d},"createdAt":"{s}"}} 693 748 , .{ subject, option, now }); 694 749 695 - var xrpc_body: std.ArrayList(u8) = .{}; 750 + var xrpc_body: std.ArrayList(u8) = .empty; 696 751 defer xrpc_body.deinit(alloc); 697 752 698 753 try xrpc_body.print(alloc, ··· 712 767 try sendJson(request, result); 713 768 } 714 769 770 + fn handleDeleteVote(request: *http.Server.Request) !void { 771 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 772 + defer arena.deinit(); 773 + const alloc = arena.allocator(); 774 + 775 + const session_did = getSessionDid(request) orelse { 776 + try sendError(request, .unauthorized, "not logged in"); 777 + return; 778 + }; 779 + 780 + const session = db.getSession(session_did) orelse { 781 + try sendError(request, .unauthorized, "session not found"); 782 + return; 783 + }; 784 + 785 + // extract poll URI from path: /api/polls/{uri}/vote 786 + const target = request.head.target; 787 + const prefix = "/api/polls/"; 788 + const suffix = "/vote"; 789 + if (!mem.startsWith(u8, target, prefix) or !mem.endsWith(u8, target, suffix)) { 790 + try sendError(request, .bad_request, "invalid path"); 791 + return; 792 + } 793 + const uri_encoded = target[prefix.len .. target.len - suffix.len]; 794 + const uri_buf = try alloc.dupe(u8, uri_encoded); 795 + const poll_uri = std.Uri.percentDecodeInPlace(uri_buf); 796 + 797 + // look up the user's vote for this poll 798 + const vote_record = db.getVoteByVoter(poll_uri, session.did) orelse { 799 + try sendError(request, .not_found, "no vote found"); 800 + return; 801 + }; 802 + 803 + // parse vote AT URI to get repo/collection/rkey 804 + if (!mem.startsWith(u8, vote_record.uri, "at://")) { 805 + try sendError(request, .internal_server_error, "invalid vote URI"); 806 + return; 807 + } 808 + const after_scheme = vote_record.uri["at://".len..]; 809 + const first_slash = mem.indexOf(u8, after_scheme, "/") orelse { 810 + try sendError(request, .internal_server_error, "invalid vote URI"); 811 + return; 812 + }; 813 + const repo = after_scheme[0..first_slash]; 814 + const after_repo = after_scheme[first_slash + 1 ..]; 815 + const second_slash = mem.indexOf(u8, after_repo, "/") orelse { 816 + try sendError(request, .internal_server_error, "invalid vote URI"); 817 + return; 818 + }; 819 + const collection = after_repo[0..second_slash]; 820 + const rkey = after_repo[second_slash + 1 ..]; 821 + 822 + // deleteRecord on PDS 823 + var xrpc_body: std.ArrayList(u8) = .empty; 824 + defer xrpc_body.deinit(alloc); 825 + 826 + try xrpc_body.print(alloc, 827 + \\{{"repo":"{s}","collection":"{s}","rkey":"{s}"}} 828 + , .{ repo, collection, rkey }); 829 + 830 + _ = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.deleteRecord", xrpc_body.items) catch |err| { 831 + if (err == error.Unauthorized) { 832 + db.deleteSession(session_did); 833 + try sendError(request, .unauthorized, "session expired"); 834 + return; 835 + } 836 + try sendError(request, .bad_gateway, "PDS request failed"); 837 + return; 838 + }; 839 + 840 + // delete from local DB 841 + db.deleteVote(vote_record.uri); 842 + 843 + try sendJson(request, "{\"ok\":true}"); 844 + } 845 + 715 846 fn handleDeletePoll(request: *http.Server.Request, uri_encoded: []const u8) !void { 716 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 847 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 717 848 defer arena.deinit(); 718 849 const alloc = arena.allocator(); 719 850 ··· 756 887 } 757 888 758 889 // deleteRecord is a POST to the PDS 759 - var xrpc_body: std.ArrayList(u8) = .{}; 890 + var xrpc_body: std.ArrayList(u8) = .empty; 760 891 defer xrpc_body.deinit(alloc); 761 892 762 893 try xrpc_body.print(alloc, ··· 783 914 784 915 const PROFILE_CACHE_SECS: i64 = 3600; // 1 hour 785 916 786 - fn fetchAndCacheProfile(alloc: std.mem.Allocator, did: []const u8) void { 917 + pub fn fetchAndCacheProfile(alloc: std.mem.Allocator, did: []const u8) void { 787 918 const url = std.fmt.allocPrint(alloc, "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={s}", .{did}) catch return; 788 919 defer alloc.free(url); 789 920 ··· 807 938 } 808 939 809 940 fn getOrFetchProfile(alloc: std.mem.Allocator, did: []const u8) db.Profile { 810 - const now = @as(i64, @intCast(std.time.timestamp())); 941 + const now = timestamp(); 811 942 812 943 if (db.getProfile(did)) |profile| { 813 944 if (now - profile.fetched_at < PROFILE_CACHE_SECS) { ··· 830 961 }; 831 962 } 832 963 964 + pub fn backfillVoterProfiles() void { 965 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 966 + defer arena.deinit(); 967 + const alloc = arena.allocator(); 968 + 969 + db.mutex.lockUncancelable(io); 970 + var rows = db.conn.rows( 971 + \\SELECT DISTINCT v.voter FROM votes v 972 + \\LEFT JOIN profiles p ON v.voter = p.did 973 + \\WHERE p.did IS NULL 974 + , .{}) catch { 975 + db.mutex.unlock(io); 976 + return; 977 + }; 978 + 979 + var missing: std.ArrayList([]const u8) = .empty; 980 + while (rows.next()) |row| { 981 + missing.append(alloc, alloc.dupe(u8, row.text(0)) catch continue) catch continue; 982 + } 983 + rows.deinit(); 984 + db.mutex.unlock(io); 985 + 986 + if (missing.items.len > 0) { 987 + std.debug.print("backfilling {d} voter profiles\n", .{missing.items.len}); 988 + } 989 + 990 + for (missing.items) |did| { 991 + fetchAndCacheProfile(alloc, did); 992 + } 993 + 994 + if (missing.items.len > 0) { 995 + std.debug.print("backfill complete\n", .{}); 996 + } 997 + } 998 + 833 999 // --- existing poll/vote read endpoints --- 834 1000 835 1001 fn handleGetPolls(request: *http.Server.Request) !void { 836 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1002 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 837 1003 defer arena.deinit(); 838 1004 const alloc = arena.allocator(); 839 1005 840 - db.mutex.lock(); 841 - defer db.mutex.unlock(); 1006 + // check session before locking db mutex (for myVote) 1007 + const session_did = getSessionDid(request); 1008 + 1009 + db.mutex.lockUncancelable(io); 1010 + defer db.mutex.unlock(io); 842 1011 843 - var response: std.ArrayList(u8) = .{}; 1012 + var response: std.ArrayList(u8) = .empty; 844 1013 defer response.deinit(alloc); 845 1014 846 1015 try response.appendSlice(alloc, "["); ··· 856 1025 857 1026 // collect poll data first so we can release db rows 858 1027 const PollRow = struct { uri: []const u8, repo: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8 }; 859 - var poll_list: std.ArrayList(PollRow) = .{}; 1028 + var poll_list: std.ArrayList(PollRow) = .empty; 860 1029 defer poll_list.deinit(alloc); 861 1030 862 1031 while (rows.next()) |row| { ··· 898 1067 const options = parsed.value.array.items; 899 1068 900 1069 // build options with counts 901 - var opts_json: std.ArrayList(u8) = .{}; 1070 + var opts_json: std.ArrayList(u8) = .empty; 902 1071 defer opts_json.deinit(alloc); 903 1072 try opts_json.appendSlice(alloc, "["); 904 1073 ··· 920 1089 } 921 1090 try opts_json.appendSlice(alloc, "]"); 922 1091 1092 + // query user's vote for this poll (already holding mutex, query inline) 1093 + var my_vote_json: std.ArrayList(u8) = .empty; 1094 + defer my_vote_json.deinit(alloc); 1095 + if (session_did) |did| { 1096 + const vrow = db.conn.row( 1097 + "SELECT option FROM votes WHERE subject = ? AND voter = ?", 1098 + .{ p.uri, did }, 1099 + ) catch null; 1100 + if (vrow) |r| { 1101 + defer r.deinit(); 1102 + try my_vote_json.print(alloc, "{d}", .{r.int(0)}); 1103 + } else { 1104 + try my_vote_json.appendSlice(alloc, "null"); 1105 + } 1106 + } else { 1107 + try my_vote_json.appendSlice(alloc, "null"); 1108 + } 1109 + 923 1110 // resolve author profile (release db mutex briefly for network fetch) 924 - db.mutex.unlock(); 1111 + db.mutex.unlock(io); 925 1112 const profile = getOrFetchProfile(alloc, p.repo); 926 - db.mutex.lock(); 1113 + db.mutex.lockUncancelable(io); 927 1114 928 1115 // escape avatar_url for JSON 929 - var avatar_json: std.ArrayList(u8) = .{}; 1116 + var avatar_json: std.ArrayList(u8) = .empty; 930 1117 defer avatar_json.deinit(alloc); 931 1118 if (profile.avatar_url.len > 0) { 932 1119 try avatar_json.appendSlice(alloc, "\""); ··· 937 1124 } 938 1125 939 1126 try response.print(alloc, 940 - \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":{s},"createdAt":"{s}","author":{{"did":"{s}","handle":"{s}","avatar":{s}}}}} 941 - , .{ p.uri, p.repo, p.rkey, p.text_json, opts_json.items, p.created_at, profile.did, profile.handle, avatar_json.items }); 1127 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":{s},"createdAt":"{s}","myVote":{s},"author":{{"did":"{s}","handle":"{s}","avatar":{s}}}}} 1128 + , .{ p.uri, p.repo, p.rkey, p.text_json, opts_json.items, p.created_at, my_vote_json.items, profile.did, profile.handle, avatar_json.items }); 942 1129 } 943 1130 944 1131 try response.appendSlice(alloc, "]"); ··· 946 1133 } 947 1134 948 1135 fn handleGetPoll(request: *http.Server.Request, uri_encoded: []const u8) !void { 949 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1136 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 950 1137 defer arena.deinit(); 951 1138 const alloc = arena.allocator(); 952 1139 953 1140 const uri_buf = try alloc.dupe(u8, uri_encoded); 954 1141 const uri = std.Uri.percentDecodeInPlace(uri_buf); 955 1142 956 - db.mutex.lock(); 957 - defer db.mutex.unlock(); 1143 + const session_did = getSessionDid(request); 1144 + 1145 + db.mutex.lockUncancelable(io); 1146 + defer db.mutex.unlock(io); 958 1147 959 1148 const row = db.conn.row("SELECT uri, repo, rkey, text, options, created_at FROM polls WHERE uri = ?", .{uri}) catch { 960 1149 try sendNotFound(request); ··· 986 1175 987 1176 const options = parsed.value.array.items; 988 1177 989 - var response: std.ArrayList(u8) = .{}; 1178 + var response: std.ArrayList(u8) = .empty; 990 1179 defer response.deinit(alloc); 991 1180 992 1181 try response.print(alloc, ··· 1010 1199 , .{ json.fmt(opt, .{}), count }); 1011 1200 } 1012 1201 1202 + // query user's vote for this poll 1203 + var my_vote_json: std.ArrayList(u8) = .empty; 1204 + defer my_vote_json.deinit(alloc); 1205 + if (session_did) |did| { 1206 + const vrow = db.conn.row( 1207 + "SELECT option FROM votes WHERE subject = ? AND voter = ?", 1208 + .{ poll_uri, did }, 1209 + ) catch null; 1210 + if (vrow) |r| { 1211 + defer r.deinit(); 1212 + try my_vote_json.print(alloc, "{d}", .{r.int(0)}); 1213 + } else { 1214 + try my_vote_json.appendSlice(alloc, "null"); 1215 + } 1216 + } else { 1217 + try my_vote_json.appendSlice(alloc, "null"); 1218 + } 1219 + 1013 1220 try response.print(alloc, 1014 - \\],"createdAt":"{s}"}} 1015 - , .{created_at}); 1221 + \\],"createdAt":"{s}","myVote":{s}}} 1222 + , .{ created_at, my_vote_json.items }); 1016 1223 1017 1224 try sendJson(request, response.items); 1018 1225 } 1019 1226 1020 1227 fn handleGetVotes(request: *http.Server.Request, uri_encoded: []const u8) !void { 1021 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1228 + var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); 1022 1229 defer arena.deinit(); 1023 1230 const alloc = arena.allocator(); 1024 1231 1025 1232 const uri_buf = try alloc.dupe(u8, uri_encoded); 1026 1233 const uri = std.Uri.percentDecodeInPlace(uri_buf); 1027 1234 1028 - db.mutex.lock(); 1029 - defer db.mutex.unlock(); 1235 + db.mutex.lockUncancelable(io); 1236 + defer db.mutex.unlock(io); 1030 1237 1031 - var response: std.ArrayList(u8) = .{}; 1238 + var response: std.ArrayList(u8) = .empty; 1032 1239 defer response.deinit(alloc); 1033 1240 1034 1241 try response.appendSlice(alloc, "["); ··· 1194 1401 1195 1402 /// simple HTTP GET that returns response body as owned slice 1196 1403 fn httpGet(alloc: std.mem.Allocator, url: []const u8) ![]u8 { 1197 - var client: std.http.Client = .{ .allocator = alloc }; 1404 + var client: std.http.Client = .{ .allocator = alloc, .io = io }; 1198 1405 defer client.deinit(); 1199 1406 1200 1407 var aw: std.Io.Writer.Allocating = .init(alloc); ··· 1262 1469 }; 1263 1470 1264 1471 fn sendParRequest(alloc: std.mem.Allocator, params: ParParams) !ParResult { 1265 - const client_assertion = try oauth.createClientAssertion(alloc, params.client_keypair, params.client_id, params.authserver_url); 1472 + const client_assertion = try oauth.createClientAssertion(alloc, io, params.client_keypair, params.client_id, params.authserver_url); 1266 1473 defer alloc.free(client_assertion); 1267 1474 1268 - const dpop_proof = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.par_url, null, null); 1475 + const dpop_proof = try oauth.createDpopProof(alloc, io, params.dpop_keypair, "POST", params.par_url, null, null); 1269 1476 defer alloc.free(dpop_proof); 1270 1477 1271 1478 const form_params = [_][2][]const u8{ ··· 1295 1502 1296 1503 alloc.free(result.body); 1297 1504 1298 - const dpop_proof2 = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.par_url, new_nonce, null); 1505 + const dpop_proof2 = try oauth.createDpopProof(alloc, io, params.dpop_keypair, "POST", params.par_url, new_nonce, null); 1299 1506 defer alloc.free(dpop_proof2); 1300 1507 1301 1508 result = try doPost(alloc, params.par_url, form_body, &.{ ··· 1328 1535 }; 1329 1536 1330 1537 fn doPost(alloc: std.mem.Allocator, url: []const u8, payload: []const u8, extra_headers: []const http.Header) !HttpResult { 1331 - var client: std.http.Client = .{ .allocator = alloc }; 1538 + var client: std.http.Client = .{ .allocator = alloc, .io = io }; 1332 1539 defer client.deinit(); 1333 1540 1334 1541 var req = try client.request(.POST, try std.Uri.parse(url), .{ ··· 1397 1604 }; 1398 1605 1399 1606 fn sendTokenRequest(alloc: std.mem.Allocator, params: TokenParams) !TokenResult { 1400 - const client_assertion = try oauth.createClientAssertion(alloc, params.client_keypair, params.client_id, params.authserver_url); 1607 + const client_assertion = try oauth.createClientAssertion(alloc, io, params.client_keypair, params.client_id, params.authserver_url); 1401 1608 defer alloc.free(client_assertion); 1402 1609 1403 - const dpop_proof = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.token_url, if (params.dpop_nonce.len > 0) params.dpop_nonce else null, null); 1610 + const dpop_proof = try oauth.createDpopProof(alloc, io, params.dpop_keypair, "POST", params.token_url, if (params.dpop_nonce.len > 0) params.dpop_nonce else null, null); 1404 1611 defer alloc.free(dpop_proof); 1405 1612 1406 1613 const form_params = [_][2][]const u8{ ··· 1425 1632 const new_nonce = result.dpop_nonce orelse return error.MissingDpopNonce; 1426 1633 alloc.free(result.body); 1427 1634 1428 - const dpop_proof2 = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.token_url, new_nonce, null); 1635 + const dpop_proof2 = try oauth.createDpopProof(alloc, io, params.dpop_keypair, "POST", params.token_url, new_nonce, null); 1429 1636 defer alloc.free(dpop_proof2); 1430 1637 1431 1638 result = try doPost(alloc, params.token_url, form_body, &.{ ··· 1469 1676 1470 1677 // inner loop: retry once on DPoP nonce error 1471 1678 const nonce_result = for (0..2) |_| { 1472 - const dpop_proof = try oauth.createDpopProof(alloc, &dpop_keypair, method_str, url, nonce, ath); 1679 + const dpop_proof = try oauth.createDpopProof(alloc, io, &dpop_keypair, method_str, url, nonce, ath); 1473 1680 defer alloc.free(dpop_proof); 1474 1681 1475 1682 var auth_header_buf: [4096]u8 = undefined; ··· 1477 1684 1478 1685 const http_method: http.Method = if (mem.eql(u8, method_str, "POST")) .POST else .GET; 1479 1686 1480 - var client: std.http.Client = .{ .allocator = alloc }; 1687 + var client: std.http.Client = .{ .allocator = alloc, .io = io }; 1481 1688 defer client.deinit(); 1482 1689 1483 1690 var req = try client.request(http_method, try std.Uri.parse(url), .{ ··· 1505 1712 var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1506 1713 1507 1714 var new_nonce: ?[]const u8 = null; 1715 + var www_authenticate: ?[]const u8 = null; 1508 1716 var header_iter = response.head.iterateHeaders(); 1509 1717 while (header_iter.next()) |header| { 1510 1718 if (std.ascii.eqlIgnoreCase(header.name, "dpop-nonce")) { 1511 1719 new_nonce = header.value; 1512 - break; 1720 + } else if (std.ascii.eqlIgnoreCase(header.name, "www-authenticate")) { 1721 + www_authenticate = header.value; 1513 1722 } 1514 1723 } 1515 1724 ··· 1525 1734 db.updateSessionNonce(session.did, .pds, n); 1526 1735 } 1527 1736 1528 - if (new_nonce != null and isDpopNonceError(response.head.status, resp_body)) { 1737 + // PDS returns use_dpop_nonce in WWW-Authenticate header (per spec), 1738 + // auth server returns it in JSON body — check both 1739 + const is_nonce_err = new_nonce != null and (isDpopNonceError(response.head.status, resp_body) or 1740 + isWwwAuthNonceError(response.head.status, www_authenticate)); 1741 + 1742 + if (is_nonce_err) { 1743 + std.debug.print("DPoP nonce error, retrying with new nonce\n", .{}); 1529 1744 alloc.free(resp_body); 1530 1745 nonce = try alloc.dupe(u8, new_nonce.?); 1531 1746 continue; ··· 1549 1764 return error.Unauthorized; 1550 1765 } 1551 1766 1552 - std.debug.print("access token expired for {s}, refreshing\n", .{session.did}); 1767 + std.debug.print("access token rejected for {s}, refreshing\n", .{session.did}); 1553 1768 const new_tokens = refreshAccessToken(alloc, session, &dpop_keypair) catch |err| { 1554 1769 std.debug.print("token refresh failed: {}\n", .{err}); 1555 1770 return error.Unauthorized; ··· 1571 1786 const client_keypair = getClientKeypair() catch return error.InvalidSessionKey; 1572 1787 const client_id = getClientId(); 1573 1788 1574 - const client_assertion = try oauth.createClientAssertion(alloc, &client_keypair, client_id, session.authserver_iss); 1789 + const client_assertion = try oauth.createClientAssertion(alloc, io, &client_keypair, client_id, session.authserver_iss); 1575 1790 defer alloc.free(client_assertion); 1576 1791 1577 1792 var authserver_nonce: ?[]const u8 = if (session.dpop_authserver_nonce.len > 0) session.dpop_authserver_nonce else null; 1578 1793 1579 1794 // retry once for DPoP nonce 1580 1795 for (0..2) |_| { 1581 - const dpop_proof = try oauth.createDpopProof(alloc, dpop_keypair, "POST", token_url, authserver_nonce, null); 1796 + const dpop_proof = try oauth.createDpopProof(alloc, io, dpop_keypair, "POST", token_url, authserver_nonce, null); 1582 1797 defer alloc.free(dpop_proof); 1583 1798 1584 1799 const form_params = [_][2][]const u8{ ··· 1643 1858 return mem.indexOf(u8, body, "use_dpop_nonce") != null; 1644 1859 } 1645 1860 1861 + /// PDS returns DPoP nonce errors via WWW-Authenticate header per AT Protocol spec: 1862 + /// WWW-Authenticate: DPoP error="use_dpop_nonce" 1863 + fn isWwwAuthNonceError(status: http.Status, www_authenticate: ?[]const u8) bool { 1864 + if (status != .unauthorized) return false; 1865 + const header = www_authenticate orelse return false; 1866 + return mem.indexOf(u8, header, "use_dpop_nonce") != null; 1867 + } 1868 + 1646 1869 // --- JSON helpers --- 1647 1870 1648 1871 fn jsonGetString(value: json.Value, key: []const u8) ?[]const u8 { ··· 1665 1888 } 1666 1889 1667 1890 fn formatTimestamp(alloc: std.mem.Allocator) ![]u8 { 1668 - const now = std.time.timestamp(); 1891 + const now = timestamp(); 1669 1892 const epoch_secs: std.time.epoch.EpochSeconds = .{ .secs = @intCast(now) }; 1670 1893 const day = epoch_secs.getDaySeconds(); 1671 1894 const year_day = epoch_secs.getEpochDay().calculateYearDay();
+15 -7
backend/src/jetstream.zig
··· 4 4 const Allocator = mem.Allocator; 5 5 const zat = @import("zat"); 6 6 const db = @import("db.zig"); 7 + const http = @import("http.zig"); 7 8 8 9 const POLL_COLLECTION = "tech.waow.pollz.poll"; 9 10 const VOTE_COLLECTION = "tech.waow.pollz.vote"; ··· 52 53 if (is_poll) { 53 54 try processPoll(allocator, uri, commit.did, commit.rkey, record.object); 54 55 } else { 55 - try processVote(uri, commit.did, record.object); 56 + try processVote(allocator, uri, commit.did, record.object); 56 57 } 57 58 }, 58 59 .delete => { ··· 77 78 const created_at_val = record.get("createdAt") orelse return; 78 79 if (created_at_val != .string) return; 79 80 80 - var options_buf: std.ArrayList(u8) = .{}; 81 + var options_buf: std.ArrayList(u8) = .empty; 81 82 defer options_buf.deinit(allocator); 82 83 try options_buf.print(allocator, "{f}", .{json.fmt(options_val, .{})}); 83 84 84 - var text_buf: std.ArrayList(u8) = .{}; 85 + var text_buf: std.ArrayList(u8) = .empty; 85 86 defer text_buf.deinit(allocator); 86 87 try text_buf.print(allocator, "{f}", .{json.fmt(text_val, .{})}); 87 88 ··· 89 90 std.debug.print("indexed poll: {s}\n", .{uri}); 90 91 } 91 92 92 - fn processVote(uri: []const u8, did: []const u8, record: json.ObjectMap) !void { 93 + fn processVote(allocator: Allocator, uri: []const u8, did: []const u8, record: json.ObjectMap) !void { 93 94 const subject_val = record.get("subject") orelse return; 94 95 if (subject_val != .string) return; 95 96 ··· 100 101 101 102 try db.insertVote(uri, subject_val.string, @as(i32, @intCast(option_val.integer)), did, created_at); 102 103 std.debug.print("indexed vote: {s} -> {s}\n", .{ uri, subject_val.string }); 104 + 105 + // cache voter profile so handles resolve in the votes API 106 + if (db.getProfile(did) == null) { 107 + http.fetchAndCacheProfile(allocator, did); 108 + } 103 109 } 104 110 105 - pub fn start(allocator: Allocator) void { 106 - var client = zat.JetstreamClient.init(allocator, .{ 111 + pub fn start(io: std.Io, allocator: Allocator) void { 112 + var client = zat.JetstreamClient.init(io, allocator, .{ 107 113 .wanted_collections = &.{ POLL_COLLECTION, VOTE_COLLECTION }, 108 114 }); 109 115 defer client.deinit(); 110 116 111 117 var handler = Handler{ .allocator = allocator }; 112 - client.subscribe(&handler); 118 + client.subscribe(&handler) catch |err| { 119 + std.debug.print("jetstream subscription ended: {s}\n", .{@errorName(err)}); 120 + }; 113 121 }
+35 -29
backend/src/main.zig
··· 1 1 const std = @import("std"); 2 - const net = std.net; 3 2 const posix = std.posix; 4 3 const Thread = std.Thread; 4 + const Io = std.Io; 5 + const net = Io.net; 5 6 const db = @import("db.zig"); 6 7 const http_server = @import("http.zig"); 7 8 const jetstream = @import("jetstream.zig"); 8 9 9 - // max concurrent http connections (prevents resource exhaustion) 10 - const MAX_HTTP_WORKERS = 16; 11 - 12 10 // socket timeout in seconds 13 11 const SOCKET_TIMEOUT_SECS = 30; 14 12 13 + // Threaded Io instance — initialized in main() before any threads are spawned. 14 + // Override debug_threaded_io so std.debug.print is multi-thread safe. 15 + var app_threaded_io: Io.Threaded = undefined; 16 + pub const std_options_debug_threaded_io: ?*Io.Threaded = &app_threaded_io; 17 + 15 18 pub fn main() !void { 16 - const allocator = std.heap.page_allocator; 19 + const allocator = std.heap.smp_allocator; 17 20 18 - // init sqlite - use DATA_PATH env or default to /data/pollz.db 19 - const db_path = posix.getenv("DATA_PATH") orelse "/data/pollz.db"; 20 - try db.init(db_path); 21 + app_threaded_io = Io.Threaded.init(allocator, .{}); 22 + const io = app_threaded_io.io(); 23 + 24 + // init sqlite 25 + const db_path: [*:0]const u8 = if (std.c.getenv("DATA_PATH")) |p| p else "/data/pollz.db"; 26 + try db.init(io, db_path); 21 27 defer db.close(); 22 28 23 - // start jetstream consumer in background 24 - const js_thread = try Thread.spawn(.{}, jetstream.start, .{allocator}); 25 - defer js_thread.join(); 29 + // init http module with io 30 + http_server.init(io); 26 31 27 - // init thread pool for http connections 28 - var pool: Thread.Pool = undefined; 29 - try pool.init(.{ 30 - .allocator = allocator, 31 - .n_jobs = MAX_HTTP_WORKERS, 32 - }); 33 - defer pool.deinit(); 32 + // backfill any voter profiles missing from cache 33 + const backfill_thread = try Thread.spawn(.{}, http_server.backfillVoterProfiles, .{}); 34 + backfill_thread.detach(); 34 35 35 - // start http server (bind to 0.0.0.0 for containerized deployments) 36 - const address = try net.Address.parseIp("0.0.0.0", 3000); 37 - var server = try address.listen(.{ .reuse_address = true }); 38 - defer server.deinit(); 36 + // start jetstream consumer in background 37 + const js_thread = try Thread.spawn(.{}, jetstream.start, .{ io, allocator }); 38 + js_thread.detach(); 39 39 40 - std.debug.print("pollz backend listening on http://127.0.0.1:3000 (max {} workers)\n", .{MAX_HTTP_WORKERS}); 40 + // start http server 41 + var address = try net.IpAddress.parse("::", 3000); 42 + var server = try net.IpAddress.listen(&address, io, .{ .reuse_address = true }); 43 + defer server.deinit(io); 44 + 45 + std.debug.print("pollz backend listening on http://[::]:3000\n", .{}); 41 46 42 47 while (true) { 43 - const conn = server.accept() catch |err| { 48 + const stream = server.accept(io) catch |err| { 44 49 std.debug.print("accept error: {}\n", .{err}); 45 50 continue; 46 51 }; 47 52 48 - // set socket timeouts to prevent slow client attacks 49 - setSocketTimeout(conn.stream.handle, SOCKET_TIMEOUT_SECS) catch |err| { 53 + setSocketTimeout(stream.socket.handle, SOCKET_TIMEOUT_SECS) catch |err| { 50 54 std.debug.print("failed to set socket timeout: {}\n", .{err}); 51 55 }; 52 56 53 - pool.spawn(http_server.handleConnection, .{conn}) catch |err| { 54 - std.debug.print("pool spawn error: {}\n", .{err}); 55 - conn.stream.close(); 57 + const t = Thread.spawn(.{}, http_server.handleConnection, .{stream}) catch |err| { 58 + std.debug.print("spawn error: {}\n", .{err}); 59 + stream.close(io); 60 + continue; 56 61 }; 62 + t.detach(); 57 63 } 58 64 } 59 65
+1
frontend/.npmrc
··· 1 + engine-strict=true
+3 -3
frontend/src/app.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 6 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 7 7 <title>pollz</title> 8 - <meta name="description" content="polls on atproto" /> 8 + <meta name="description" content="create and vote on polls" /> 9 9 <meta property="og:type" content="website" /> 10 10 <meta property="og:title" content="pollz" /> 11 - <meta property="og:description" content="polls on atproto" /> 11 + <meta property="og:description" content="create and vote on polls" /> 12 12 <meta property="og:url" content="https://pollz.waow.tech" /> 13 13 <meta property="og:site_name" content="pollz" /> 14 14 <meta name="twitter:card" content="summary" /> 15 15 <meta name="twitter:title" content="pollz" /> 16 - <meta name="twitter:description" content="polls on atproto" /> 16 + <meta name="twitter:description" content="create and vote on polls" /> 17 17 %sveltekit.head% 18 18 </head> 19 19 <body data-sveltekit-preload-data="hover">
+6
frontend/src/lib/api.ts
··· 31 31 text: string; 32 32 options: { text: string; count: number }[]; 33 33 createdAt: string; 34 + myVote?: number | null; 34 35 author?: { did: string; handle: string; avatar: string | null }; 35 36 }; 36 37 ··· 41 42 text: string; 42 43 options: { text: string; count: number }[]; 43 44 createdAt: string; 45 + myVote?: number | null; 44 46 }; 45 47 46 48 export type Vote = { ··· 90 92 headers: { 'Content-Type': 'application/json' }, 91 93 body: JSON.stringify({ subject: pollUri, option }) 92 94 }); 95 + } 96 + 97 + export async function deleteVote(pollUri: string) { 98 + return api(`/api/polls/${encodeURIComponent(pollUri)}/vote`, { method: 'DELETE' }); 93 99 } 94 100 95 101 export async function getMe(): Promise<User | null> {
+210
frontend/src/lib/components/HandleAutocomplete.svelte
··· 1 + <script lang="ts"> 2 + const TYPEAHEAD = 'https://typeahead.waow.tech'; 3 + 4 + interface Actor { 5 + did: string; 6 + handle: string; 7 + displayName?: string; 8 + avatar?: string; 9 + } 10 + 11 + interface Props { 12 + value: string; 13 + onSelect: (handle: string) => void; 14 + onEnter?: () => void; 15 + placeholder?: string; 16 + } 17 + 18 + let { value = $bindable(''), onSelect, onEnter, placeholder = 'handle' }: Props = $props(); 19 + 20 + let results = $state<Actor[]>([]); 21 + let showResults = $state(false); 22 + let searchTimeout: ReturnType<typeof setTimeout> | null = null; 23 + 24 + async function search() { 25 + if (value.length < 2) { 26 + results = []; 27 + showResults = false; 28 + return; 29 + } 30 + try { 31 + const res = await fetch( 32 + `${TYPEAHEAD}/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(value)}&limit=6`, 33 + { headers: { 'X-Client': 'pollz.waow.tech' } } 34 + ); 35 + if (res.ok) { 36 + const data = await res.json(); 37 + results = data.actors || []; 38 + showResults = results.length > 0; 39 + } 40 + } catch { 41 + // silent 42 + } 43 + } 44 + 45 + function handleInput() { 46 + if (searchTimeout) clearTimeout(searchTimeout); 47 + searchTimeout = setTimeout(search, 200); 48 + } 49 + 50 + function select(e: MouseEvent, actor: Actor) { 51 + e.stopPropagation(); 52 + value = actor.handle; 53 + onSelect(actor.handle); 54 + results = []; 55 + showResults = false; 56 + } 57 + 58 + function handleClickOutside(e: MouseEvent) { 59 + if (!(e.target as HTMLElement).closest('.handle-autocomplete')) { 60 + showResults = false; 61 + } 62 + } 63 + 64 + function handleKeydown(e: KeyboardEvent) { 65 + if (e.key === 'Escape') { 66 + showResults = false; 67 + } else if (e.key === 'Enter') { 68 + showResults = false; 69 + onEnter?.(); 70 + } 71 + } 72 + 73 + $effect(() => { 74 + return () => { 75 + if (searchTimeout) clearTimeout(searchTimeout); 76 + }; 77 + }); 78 + </script> 79 + 80 + <svelte:window onclick={handleClickOutside} /> 81 + 82 + <div class="handle-autocomplete"> 83 + <input 84 + type="text" 85 + bind:value 86 + oninput={handleInput} 87 + onkeydown={handleKeydown} 88 + onfocus={() => { if (results.length > 0) showResults = true; }} 89 + {placeholder} 90 + autocomplete="off" 91 + autocapitalize="off" 92 + spellcheck="false" 93 + /> 94 + 95 + {#if showResults && results.length > 0} 96 + <div class="results"> 97 + {#each results as actor (actor.did)} 98 + <button type="button" class="result" onclick={(e) => select(e, actor)}> 99 + {#if actor.avatar} 100 + <img src={actor.avatar} alt="" class="avatar" /> 101 + {:else} 102 + <div class="avatar-placeholder"></div> 103 + {/if} 104 + <div class="info"> 105 + {#if actor.displayName} 106 + <span class="name">{actor.displayName}</span> 107 + {/if} 108 + <span class="handle">@{actor.handle}</span> 109 + </div> 110 + </button> 111 + {/each} 112 + </div> 113 + {/if} 114 + </div> 115 + 116 + <style> 117 + .handle-autocomplete { 118 + position: relative; 119 + } 120 + 121 + input { 122 + width: 140px; 123 + padding: 0.25rem 0.4rem; 124 + font-size: 13px; 125 + font-family: monospace; 126 + background: #111; 127 + border: 1px solid #333; 128 + color: #ccc; 129 + } 130 + 131 + input:focus { 132 + outline: 1px solid #444; 133 + } 134 + 135 + .results { 136 + position: absolute; 137 + top: 100%; 138 + right: 0; 139 + z-index: 100; 140 + width: 260px; 141 + max-height: 240px; 142 + overflow-y: auto; 143 + background: #111; 144 + border: 1px solid #333; 145 + margin-top: 4px; 146 + } 147 + 148 + .result { 149 + width: 100%; 150 + display: flex; 151 + align-items: center; 152 + gap: 0.5rem; 153 + padding: 0.4rem 0.5rem; 154 + background: transparent; 155 + border: none; 156 + border-bottom: 1px solid #1a1a1a; 157 + color: #ccc; 158 + text-align: left; 159 + font-family: monospace; 160 + font-size: 12px; 161 + cursor: pointer; 162 + } 163 + 164 + .result:last-child { 165 + border-bottom: none; 166 + } 167 + 168 + .result:hover { 169 + background: #1a1a1a; 170 + } 171 + 172 + .avatar { 173 + width: 24px; 174 + height: 24px; 175 + border-radius: 50%; 176 + object-fit: cover; 177 + flex-shrink: 0; 178 + } 179 + 180 + .avatar-placeholder { 181 + width: 24px; 182 + height: 24px; 183 + border-radius: 50%; 184 + background: #333; 185 + flex-shrink: 0; 186 + } 187 + 188 + .info { 189 + flex: 1; 190 + min-width: 0; 191 + overflow: hidden; 192 + display: flex; 193 + flex-direction: column; 194 + } 195 + 196 + .name { 197 + color: #ccc; 198 + white-space: nowrap; 199 + overflow: hidden; 200 + text-overflow: ellipsis; 201 + } 202 + 203 + .handle { 204 + color: #666; 205 + white-space: nowrap; 206 + overflow: hidden; 207 + text-overflow: ellipsis; 208 + font-size: 11px; 209 + } 210 + </style>
+14 -15
frontend/src/routes/+layout.svelte
··· 3 3 import { loadUser, getUser, setUser, isLoaded } from '$lib/user.svelte'; 4 4 import { logout as apiLogout, loginUrl, setOnUnauthorized } from '$lib/api'; 5 5 import { onMount } from 'svelte'; 6 + import HandleAutocomplete from '$lib/components/HandleAutocomplete.svelte'; 6 7 7 8 let { children } = $props(); 8 9 9 10 let handle = $state(''); 10 11 11 12 onMount(() => { 12 - loadUser(); 13 + // skip loadUser on callback page — the exchange sets the cookie first 14 + if (!window.location.pathname.startsWith('/auth/callback')) { 15 + loadUser(); 16 + } 13 17 setOnUnauthorized(() => setUser(null)); 14 18 }); 15 19 ··· 17 21 apiLogout().then(() => setUser(null)); 18 22 } 19 23 20 - function doLogin() { 21 - if (handle.trim()) { 22 - window.location.href = loginUrl(handle.trim()); 24 + function doLogin(h?: string) { 25 + const target = (h ?? handle).trim(); 26 + if (target) { 27 + window.location.href = loginUrl(target); 23 28 } 24 29 } 25 30 </script> ··· 37 42 <a href="/new">new</a> 38 43 <button onclick={doLogout}>logout</button> 39 44 {:else} 40 - <input 41 - type="text" 45 + <HandleAutocomplete 46 + bind:value={handle} 47 + onSelect={(h) => doLogin(h)} 48 + onEnter={() => doLogin()} 42 49 placeholder="handle" 43 - bind:value={handle} 44 - onkeydown={(e) => e.key === 'Enter' && doLogin()} 45 50 /> 46 - <button onclick={doLogin}>login</button> 51 + <button onclick={() => doLogin()}>login</button> 47 52 {/if} 48 53 {/if} 49 54 </nav> ··· 81 86 display: flex; 82 87 align-items: center; 83 88 gap: 0.75rem; 84 - } 85 - 86 - nav input { 87 - width: 140px; 88 - padding: 0.25rem 0.4rem; 89 - font-size: 13px; 90 89 } 91 90 92 91 nav button {
+45 -7
frontend/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { fetchPolls, vote as apiVote, fetchPoll, deletePoll, type Poll } from '$lib/api'; 2 + import { fetchPolls, vote as apiVote, deleteVote, fetchPoll, deletePoll, type Poll } from '$lib/api'; 3 3 import { ago, fullDate } from '$lib/utils'; 4 4 import { getUser } from '$lib/user.svelte'; 5 5 import { onMount } from 'svelte'; ··· 9 9 let loading = $state(true); 10 10 let votingUri = $state<string | null>(null); 11 11 let statusMap: Record<string, string> = $state({}); 12 + 13 + function copyLink(poll: Poll) { 14 + const url = `${window.location.origin}/poll/${poll.repo}/${poll.rkey}`; 15 + navigator.clipboard.writeText(url); 16 + setStatus(poll.uri, 'link copied', 1500); 17 + } 12 18 13 19 onMount(async () => { 14 20 try { ··· 59 65 if (!getUser()) return; 60 66 if (votingUri) return; 61 67 68 + const isUnvote = poll.myVote === optionIndex; 69 + const isSwitch = poll.myVote != null && poll.myVote !== optionIndex; 70 + 62 71 votingUri = poll.uri; 63 - setStatus(poll.uri, 'voting...', 10000); 72 + setStatus(poll.uri, isUnvote ? 'removing vote...' : 'voting...', 10000); 64 73 try { 65 - await apiVote(poll.uri, optionIndex); 74 + if (isUnvote) { 75 + await deleteVote(poll.uri); 76 + } else { 77 + await apiVote(poll.uri, optionIndex); 78 + } 66 79 67 80 const beforeCounts = poll.options.map((o) => o.count); 68 81 let confirmed = false; ··· 72 85 if (fresh && fresh.options.some((o, j) => o.count !== beforeCounts[j])) { 73 86 const idx = polls.findIndex((p) => p.uri === poll.uri); 74 87 if (idx !== -1) { 75 - polls[idx] = { ...polls[idx], options: fresh.options }; 88 + polls[idx] = { ...polls[idx], options: fresh.options, myVote: isUnvote ? null : optionIndex }; 76 89 } 77 90 confirmed = true; 78 91 break; ··· 80 93 } 81 94 if (confirmed) { 82 95 invalidateVotesCache(poll.uri); 83 - setStatus(poll.uri, 'voted', 2000); 96 + if (isUnvote) { 97 + setStatus(poll.uri, 'unvoted', 2000); 98 + } else if (isSwitch) { 99 + setStatus(poll.uri, 'vote changed', 2000); 100 + } else { 101 + setStatus(poll.uri, 'voted', 2000); 102 + } 84 103 } else { 85 - setStatus(poll.uri, 'vote may still be processing', 3000); 104 + setStatus(poll.uri, isUnvote ? 'unvote may still be processing' : 'vote may still be processing', 3000); 86 105 } 87 106 } catch (e) { 88 107 console.error('vote failed', e); ··· 127 146 {#each poll.options as option, i (i)} 128 147 {@const p = pct(option.count, total)} 129 148 <button 130 - class="option" 149 + class="option {poll.myVote === i ? 'voted' : ''}" 131 150 disabled={votingUri === poll.uri || !getUser()} 132 151 onclick={() => handleVote(poll, i)} 133 152 > ··· 143 162 <VotersTooltip pollUri={poll.uri} options={poll.options}> 144 163 <span class="vote-count">{total} {total === 1 ? 'vote' : 'votes'}</span> 145 164 </VotersTooltip> 165 + &middot; <button class="share-btn" onclick={() => copyLink(poll)}>copy link</button> 146 166 {#if isOwner(poll)} 147 167 &middot; <button class="delete-btn" onclick={() => handleDelete(poll)}>delete</button> 148 168 {/if} ··· 238 258 -webkit-tap-highlight-color: transparent; 239 259 } 240 260 261 + .option.voted { 262 + border-left: 2px solid #4a9; 263 + } 264 + 241 265 .option:hover:not(:disabled) { 242 266 border-color: #444; 243 267 } ··· 280 304 .vote-count { 281 305 cursor: default; 282 306 border-bottom: 1px dotted #444; 307 + } 308 + 309 + .share-btn { 310 + background: none; 311 + border: none; 312 + color: #555; 313 + font-family: monospace; 314 + font-size: 12px; 315 + cursor: pointer; 316 + padding: 0; 317 + } 318 + 319 + .share-btn:hover { 320 + color: #888; 283 321 } 284 322 285 323 .delete-btn {
+2 -4
frontend/src/routes/auth/callback/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { exchangeToken } from '$lib/api'; 3 - import { setUser } from '$lib/user.svelte'; 4 - import { goto } from '$app/navigation'; 5 3 import { page } from '$app/state'; 6 4 import { onMount } from 'svelte'; 7 5 ··· 16 14 try { 17 15 const result = await exchangeToken(token); 18 16 if (result) { 19 - setUser(result); 20 - goto('/'); 17 + // full reload so loadUser() runs fresh with the new cookie 18 + window.location.href = '/'; 21 19 } else { 22 20 error = true; 23 21 }
+2 -3
frontend/src/routes/new/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { createPoll, fetchPoll } from '$lib/api'; 3 3 import { getUser } from '$lib/user.svelte'; 4 - import { goto } from '$app/navigation'; 5 4 6 5 let question = $state(''); 7 6 let optionsText = $state(''); ··· 29 28 await new Promise((r) => setTimeout(r, 500)); 30 29 const poll = await fetchPoll(uri); 31 30 if (poll) { 32 - goto('/'); 31 + window.location.href = '/'; 33 32 return; 34 33 } 35 34 } 36 35 } 37 - goto('/'); 36 + window.location.href = '/'; 38 37 } catch (e) { 39 38 status = e instanceof Error ? e.message : 'failed to create poll'; 40 39 }
+34 -5
frontend/src/routes/poll/[repo]/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { fetchPoll, vote as apiVote, deletePoll, type PollDetail } from '$lib/api'; 2 + import { fetchPoll, vote as apiVote, deleteVote, deletePoll, type PollDetail } from '$lib/api'; 3 3 import { ago, fullDate } from '$lib/utils'; 4 4 import { getUser } from '$lib/user.svelte'; 5 5 import { page } from '$app/state'; ··· 58 58 return; 59 59 } 60 60 if (!poll) return; 61 + 62 + const isUnvote = poll.myVote === optionIndex; 61 63 voting = true; 62 - status = 'voting...'; 64 + status = isUnvote ? 'removing vote...' : 'voting...'; 65 + 63 66 try { 64 - await apiVote(uri, optionIndex); 67 + if (isUnvote) { 68 + await deleteVote(uri); 69 + } else { 70 + await apiVote(uri, optionIndex); 71 + } 65 72 66 73 const beforeCounts = poll.options.map((o) => o.count); 67 74 let confirmed = false; ··· 75 82 } 76 83 if (confirmed) { 77 84 invalidateVotesCache(uri); 78 - status = 'voted'; 85 + status = isUnvote ? 'unvoted' : poll?.myVote !== optionIndex ? 'vote changed' : 'voted'; 79 86 setTimeout(() => (status = ''), 2000); 80 87 } else { 81 88 status = 'vote may still be processing'; 82 89 setTimeout(() => (status = ''), 3000); 83 90 } 84 91 } catch (e) { 85 - status = e instanceof Error ? e.message : 'failed to vote'; 92 + status = e instanceof Error ? e.message : isUnvote ? 'failed to remove vote' : 'failed to vote'; 86 93 setTimeout(() => (status = ''), 3000); 87 94 } finally { 88 95 voting = false; ··· 96 103 } 97 104 </script> 98 105 106 + <svelte:head> 107 + {#if poll} 108 + <title>{poll.text} - pollz</title> 109 + <meta name="description" content="{poll.options.map(o => o.text).join(' / ')} - {totalVotes} vote{totalVotes === 1 ? '' : 's'}" /> 110 + <meta property="og:title" content={poll.text} /> 111 + <meta property="og:description" content="{poll.options.map(o => o.text).join(' / ')} - {totalVotes} vote{totalVotes === 1 ? '' : 's'}" /> 112 + <meta property="og:url" content="https://pollz.waow.tech/poll/{repo}/{rkey}" /> 113 + <meta property="og:type" content="website" /> 114 + <meta property="og:site_name" content="pollz" /> 115 + <meta name="twitter:card" content="summary" /> 116 + <meta name="twitter:title" content={poll.text} /> 117 + <meta name="twitter:description" content="{poll.options.map(o => o.text).join(' / ')} - {totalVotes} vote{totalVotes === 1 ? '' : 's'}" /> 118 + {:else} 119 + <title>pollz</title> 120 + {/if} 121 + </svelte:head> 122 + 99 123 <div class="poll-detail"> 100 124 <a href="/" class="back">&larr; all polls</a> 101 125 ··· 119 143 {@const pct = totalVotes > 0 ? Math.round((option.count / totalVotes) * 100) : 0} 120 144 <button 121 145 class="option" 146 + class:voted={poll.myVote === i} 122 147 disabled={voting} 123 148 onclick={() => handleVote(i)} 124 149 > ··· 190 215 text-align: left; 191 216 min-height: 44px; 192 217 -webkit-tap-highlight-color: transparent; 218 + } 219 + 220 + .poll-detail .option.voted { 221 + border-left: 2px solid #4a9; 193 222 } 194 223 195 224 .poll-detail .option:hover {
+68
frontend/static/_worker.js
··· 1 + const API = 'https://api.pollz.waow.tech'; 2 + 3 + function escapeHtml(str) { 4 + return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 5 + } 6 + 7 + export default { 8 + async fetch(request, env) { 9 + const url = new URL(request.url); 10 + const pollMatch = url.pathname.match(/^\/poll\/([^/]+)\/([^/]+)$/); 11 + 12 + if (pollMatch) { 13 + const [, repo, rkey] = pollMatch; 14 + const uri = `at://${repo}/tech.waow.pollz.poll/${rkey}`; 15 + 16 + try { 17 + const apiRes = await fetch(`${API}/api/polls/${encodeURIComponent(uri)}`); 18 + if (apiRes.ok) { 19 + const poll = await apiRes.json(); 20 + const assetRes = await env.ASSETS.fetch(request); 21 + let html = await assetRes.text(); 22 + 23 + const totalVotes = poll.options.reduce((s, o) => s + o.count, 0); 24 + const desc = poll.options.map((o) => o.text).join(' / ') + 25 + ` - ${totalVotes} vote${totalVotes === 1 ? '' : 's'}`; 26 + const title = escapeHtml(poll.text); 27 + const escapedDesc = escapeHtml(desc); 28 + const pageTitle = `${title} - pollz`; 29 + 30 + html = html 31 + .replace(/<title>pollz<\/title>/, `<title>${pageTitle}</title>`) 32 + .replace( 33 + /<meta name="description" content="[^"]*"/, 34 + `<meta name="description" content="${escapedDesc}"` 35 + ) 36 + .replace( 37 + /<meta property="og:title" content="[^"]*"/, 38 + `<meta property="og:title" content="${title}"` 39 + ) 40 + .replace( 41 + /<meta property="og:description" content="[^"]*"/, 42 + `<meta property="og:description" content="${escapedDesc}"` 43 + ) 44 + .replace( 45 + /<meta property="og:url" content="[^"]*"/, 46 + `<meta property="og:url" content="${url.href}"` 47 + ) 48 + .replace( 49 + /<meta name="twitter:title" content="[^"]*"/, 50 + `<meta name="twitter:title" content="${title}"` 51 + ) 52 + .replace( 53 + /<meta name="twitter:description" content="[^"]*"/, 54 + `<meta name="twitter:description" content="${escapedDesc}"` 55 + ); 56 + 57 + return new Response(html, { 58 + headers: { 'content-type': 'text/html;charset=utf-8' } 59 + }); 60 + } 61 + } catch { 62 + // fall through to static 63 + } 64 + } 65 + 66 + return env.ASSETS.fetch(request); 67 + } 68 + };
+5 -1
frontend/static/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="85" font-family="serif">◉</text></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 + <rect x="4" y="18" width="6" height="10" rx="1" fill="#4a9"/> 3 + <rect x="13" y="10" width="6" height="18" rx="1" fill="#4a9"/> 4 + <rect x="22" y="4" width="6" height="24" rx="1" fill="#4a9"/> 5 + </svg>